sf-queue-sdk 0.4.0b131__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {sf_queue_sdk-0.4.0b131 → sf_queue_sdk-0.5.0}/.gitignore +7 -0
  2. sf_queue_sdk-0.4.0b131/README.md → sf_queue_sdk-0.5.0/PKG-INFO +45 -2
  3. sf_queue_sdk-0.4.0b131/PKG-INFO → sf_queue_sdk-0.5.0/README.md +37 -10
  4. {sf_queue_sdk-0.4.0b131 → sf_queue_sdk-0.5.0}/pyproject.toml +1 -1
  5. {sf_queue_sdk-0.4.0b131 → sf_queue_sdk-0.5.0}/queue_sdk/__init__.py +1 -1
  6. {sf_queue_sdk-0.4.0b131 → sf_queue_sdk-0.5.0}/queue_sdk/queues/base_queue.py +52 -1
  7. {sf_queue_sdk-0.4.0b131 → sf_queue_sdk-0.5.0}/queue_sdk/queues/document_upload_queue.py +14 -0
  8. {sf_queue_sdk-0.4.0b131 → sf_queue_sdk-0.5.0}/queue_sdk/redis_client.py +10 -0
  9. {sf_queue_sdk-0.4.0b131 → sf_queue_sdk-0.5.0}/queue_sdk/types.py +9 -0
  10. sf_queue_sdk-0.5.0/tests/test_idempotency.py +109 -0
  11. {sf_queue_sdk-0.4.0b131 → sf_queue_sdk-0.5.0}/queue_sdk/client.py +0 -0
  12. {sf_queue_sdk-0.4.0b131 → sf_queue_sdk-0.5.0}/queue_sdk/generated/__init__.py +0 -0
  13. {sf_queue_sdk-0.4.0b131 → sf_queue_sdk-0.5.0}/queue_sdk/generated/queue/email/v1/email_pb2.py +0 -0
  14. {sf_queue_sdk-0.4.0b131 → sf_queue_sdk-0.5.0}/queue_sdk/generated/queue/email/v1/email_pb2.pyi +0 -0
  15. {sf_queue_sdk-0.4.0b131 → sf_queue_sdk-0.5.0}/queue_sdk/generated/queue/prompt_ingest/v1/prompt_ingest_pb2.py +0 -0
  16. {sf_queue_sdk-0.4.0b131 → sf_queue_sdk-0.5.0}/queue_sdk/generated/queue/prompt_ingest/v1/prompt_ingest_pb2.pyi +0 -0
  17. {sf_queue_sdk-0.4.0b131 → sf_queue_sdk-0.5.0}/queue_sdk/generated/queue/tool_assignment/v1/tool_assignment_pb2.py +0 -0
  18. {sf_queue_sdk-0.4.0b131 → sf_queue_sdk-0.5.0}/queue_sdk/generated/queue/tool_assignment/v1/tool_assignment_pb2.pyi +0 -0
  19. {sf_queue_sdk-0.4.0b131 → sf_queue_sdk-0.5.0}/queue_sdk/generated/queue/topic_assignment/v1/topic_assignment_pb2.py +0 -0
  20. {sf_queue_sdk-0.4.0b131 → sf_queue_sdk-0.5.0}/queue_sdk/generated/queue/topic_assignment/v1/topic_assignment_pb2.pyi +0 -0
  21. {sf_queue_sdk-0.4.0b131 → sf_queue_sdk-0.5.0}/queue_sdk/query.py +0 -0
  22. {sf_queue_sdk-0.4.0b131 → sf_queue_sdk-0.5.0}/queue_sdk/queues/__init__.py +0 -0
  23. {sf_queue_sdk-0.4.0b131 → sf_queue_sdk-0.5.0}/queue_sdk/queues/document_notes_queue.py +0 -0
  24. {sf_queue_sdk-0.4.0b131 → sf_queue_sdk-0.5.0}/queue_sdk/queues/document_persist_queue.py +0 -0
  25. {sf_queue_sdk-0.4.0b131 → sf_queue_sdk-0.5.0}/queue_sdk/queues/email_queue.py +0 -0
  26. {sf_queue_sdk-0.4.0b131 → sf_queue_sdk-0.5.0}/queue_sdk/queues/pdf_to_image_queue.py +0 -0
  27. {sf_queue_sdk-0.4.0b131 → sf_queue_sdk-0.5.0}/queue_sdk/queues/prompt_ingest_queue.py +0 -0
  28. {sf_queue_sdk-0.4.0b131 → sf_queue_sdk-0.5.0}/queue_sdk/queues/recommendation_queue.py +0 -0
  29. {sf_queue_sdk-0.4.0b131 → sf_queue_sdk-0.5.0}/queue_sdk/queues/tool_assignment_queue.py +0 -0
  30. {sf_queue_sdk-0.4.0b131 → sf_queue_sdk-0.5.0}/queue_sdk/queues/topic_assignment_queue.py +0 -0
  31. {sf_queue_sdk-0.4.0b131 → sf_queue_sdk-0.5.0}/queue_sdk/sanitize.py +0 -0
@@ -59,3 +59,10 @@ TODO-document-upload-queue.md
59
59
  # Local pipeline test output
60
60
  scripts/.env
61
61
  scripts/local-pdfs/
62
+ scripts/mp3/
63
+ scripts/ppt/
64
+ scripts/docx/
65
+ scripts/mp3-results/
66
+ scripts/studyset/.env
67
+ scripts/studyset/downloads/
68
+ scripts/failed-files/
@@ -1,3 +1,11 @@
1
+ Metadata-Version: 2.5
2
+ Name: sf-queue-sdk
3
+ Version: 0.5.0
4
+ Summary: Python SDK for sf-queue - Redis-based queue system
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: redis>=5.0.0
7
+ Description-Content-Type: text/markdown
8
+
1
9
  # sf-queue-sdk
2
10
 
3
11
  Python SDK for sf-queue. Enqueues emails via Redis Streams with optional blocking confirmation from the Go consumer service.
@@ -546,6 +554,33 @@ result = client.document_upload.send(
546
554
  print("Enqueued:", result.message_id)
547
555
  ```
548
556
 
557
+ ### Idempotent verified uploads
558
+
559
+ Pass `idempotency_key` when retrying a verified upload. Calls with the same
560
+ environment, queue, and key create one Redis stream entry and return the same
561
+ `message_id`.
562
+
563
+ ```python
564
+ result = client.document_upload.send(
565
+ uploaded_file_id="file_abc123",
566
+ study_set_id="set_xyz",
567
+ user_id="user_123",
568
+ text_extract_s3_key="uploadedfiles/file_abc123/textContent",
569
+ pdf_s3_key="uploads/document.pdf",
570
+ file_type="application/pdf",
571
+ effective_file_type="application/pdf",
572
+ filename="Chapter 5 - Thermodynamics.pdf",
573
+ material_title="Thermodynamics",
574
+ url="https://storage.example.com/uploads/document.pdf",
575
+ generate_notes=True,
576
+ idempotency_key="verified-upload:file_abc123",
577
+ )
578
+ ```
579
+
580
+ The key is scoped to `document-upload` and the configured environment. The SDK
581
+ stores a SHA-256 digest of the caller key in Redis and keeps the idempotency
582
+ mapping for seven days.
583
+
549
584
  ### With optional fields
550
585
 
551
586
  ```python
@@ -591,7 +626,7 @@ result = client.document_upload.send(
591
626
  | `generate_notes` | `bool` | Yes | Whether to generate study notes |
592
627
  | `generate_study_path` | `bool` | Yes | Whether to generate a study path |
593
628
  | `splitting` | `str` | No | Splitting strategy for sources with at least 40,000 tokens (default: `"auto"`). Smaller sources always persist as one material after per-topic note generation. |
594
- | `language` | `str` | No | Target language code (default: `"en"`) |
629
+ | `language` | `str` | No | Output language as an app ISO code, e.g. `"de"` — not a locale like `"de-DE"`. Note the non-standard `cz` (Czech) and `gr` (Greek). (default: `"en"`) |
595
630
  | `use_markdown_ocr` | `bool` | No | Use Markdown OCR for image interpretation (default: `False`) |
596
631
  | `flashcard_amount` | `int` | No | Number of flashcards to generate (default: `12`) |
597
632
  | `new_url` | `Optional[str]` | No | Alternative URL if file was re-uploaded |
@@ -605,6 +640,14 @@ result = client.document_upload.send(
605
640
  | `youtube_title` | `Optional[str]` | No | Title override for YouTube video transcripts |
606
641
  | `skip_material_creation` | `Optional[bool]` | No | Skip folder and material creation when the material already exists (e.g. live lectures) |
607
642
  | `existing_material_id` | `Optional[str]` | No | ID of an existing material to link to topics instead of creating new ones (used with `skip_material_creation`) |
643
+ | `params` | `Optional[dict]` | No | Typed extensible flags for pipeline behavior (see below) |
644
+ | `idempotency_key` | `Optional[str]` | No | Caller key for retry-safe enqueueing. Duplicate environment/queue/key sends return the original `message_id`. |
645
+
646
+ #### `params` fields
647
+
648
+ | Field | Type | Description |
649
+ |-------|------|-------------|
650
+ | `liveLecture` | `bool` | When true, forces `generateNotes=false` and `skipMaterialCreation=true` in the pipeline. Clustering still runs to produce sections/topics for study path generation. Use with `existing_material_id` and `extracted_content`. |
608
651
 
609
652
  ### Response Type
610
653
 
@@ -787,7 +830,7 @@ result = client.document_persist.send(
787
830
  | `study_set_id` | `str` | Yes | Study set to persist materials into |
788
831
  | `user_id` | `str` | Yes | User who uploaded the document |
789
832
  | `stash_key` | `str` | Yes | GCS key of the clustering stash JSON |
790
- | `language` | `str` | No | Target language code (default: `"en"`) |
833
+ | `language` | `str` | No | Output language as an app ISO code, e.g. `"de"` — not a locale like `"de-DE"`. Note the non-standard `cz` (Czech) and `gr` (Greek). (default: `"en"`) |
791
834
  | `callback_url` | `Optional[str]` | No | URL to POST results to after persistence |
792
835
  | `callback_api_key` | `Optional[str]` | No | Auth key for the callback |
793
836
  | `approved_topic_ids` | `Optional[list[str]]` | No | Filter to only these topic IDs from the stash (omit to persist all) |
@@ -1,11 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: sf-queue-sdk
3
- Version: 0.4.0b131
4
- Summary: Python SDK for sf-queue - Redis-based queue system
5
- Requires-Python: >=3.11
6
- Requires-Dist: redis>=5.0.0
7
- Description-Content-Type: text/markdown
8
-
9
1
  # sf-queue-sdk
10
2
 
11
3
  Python SDK for sf-queue. Enqueues emails via Redis Streams with optional blocking confirmation from the Go consumer service.
@@ -554,6 +546,33 @@ result = client.document_upload.send(
554
546
  print("Enqueued:", result.message_id)
555
547
  ```
556
548
 
549
+ ### Idempotent verified uploads
550
+
551
+ Pass `idempotency_key` when retrying a verified upload. Calls with the same
552
+ environment, queue, and key create one Redis stream entry and return the same
553
+ `message_id`.
554
+
555
+ ```python
556
+ result = client.document_upload.send(
557
+ uploaded_file_id="file_abc123",
558
+ study_set_id="set_xyz",
559
+ user_id="user_123",
560
+ text_extract_s3_key="uploadedfiles/file_abc123/textContent",
561
+ pdf_s3_key="uploads/document.pdf",
562
+ file_type="application/pdf",
563
+ effective_file_type="application/pdf",
564
+ filename="Chapter 5 - Thermodynamics.pdf",
565
+ material_title="Thermodynamics",
566
+ url="https://storage.example.com/uploads/document.pdf",
567
+ generate_notes=True,
568
+ idempotency_key="verified-upload:file_abc123",
569
+ )
570
+ ```
571
+
572
+ The key is scoped to `document-upload` and the configured environment. The SDK
573
+ stores a SHA-256 digest of the caller key in Redis and keeps the idempotency
574
+ mapping for seven days.
575
+
557
576
  ### With optional fields
558
577
 
559
578
  ```python
@@ -599,7 +618,7 @@ result = client.document_upload.send(
599
618
  | `generate_notes` | `bool` | Yes | Whether to generate study notes |
600
619
  | `generate_study_path` | `bool` | Yes | Whether to generate a study path |
601
620
  | `splitting` | `str` | No | Splitting strategy for sources with at least 40,000 tokens (default: `"auto"`). Smaller sources always persist as one material after per-topic note generation. |
602
- | `language` | `str` | No | Target language code (default: `"en"`) |
621
+ | `language` | `str` | No | Output language as an app ISO code, e.g. `"de"` — not a locale like `"de-DE"`. Note the non-standard `cz` (Czech) and `gr` (Greek). (default: `"en"`) |
603
622
  | `use_markdown_ocr` | `bool` | No | Use Markdown OCR for image interpretation (default: `False`) |
604
623
  | `flashcard_amount` | `int` | No | Number of flashcards to generate (default: `12`) |
605
624
  | `new_url` | `Optional[str]` | No | Alternative URL if file was re-uploaded |
@@ -613,6 +632,14 @@ result = client.document_upload.send(
613
632
  | `youtube_title` | `Optional[str]` | No | Title override for YouTube video transcripts |
614
633
  | `skip_material_creation` | `Optional[bool]` | No | Skip folder and material creation when the material already exists (e.g. live lectures) |
615
634
  | `existing_material_id` | `Optional[str]` | No | ID of an existing material to link to topics instead of creating new ones (used with `skip_material_creation`) |
635
+ | `params` | `Optional[dict]` | No | Typed extensible flags for pipeline behavior (see below) |
636
+ | `idempotency_key` | `Optional[str]` | No | Caller key for retry-safe enqueueing. Duplicate environment/queue/key sends return the original `message_id`. |
637
+
638
+ #### `params` fields
639
+
640
+ | Field | Type | Description |
641
+ |-------|------|-------------|
642
+ | `liveLecture` | `bool` | When true, forces `generateNotes=false` and `skipMaterialCreation=true` in the pipeline. Clustering still runs to produce sections/topics for study path generation. Use with `existing_material_id` and `extracted_content`. |
616
643
 
617
644
  ### Response Type
618
645
 
@@ -795,7 +822,7 @@ result = client.document_persist.send(
795
822
  | `study_set_id` | `str` | Yes | Study set to persist materials into |
796
823
  | `user_id` | `str` | Yes | User who uploaded the document |
797
824
  | `stash_key` | `str` | Yes | GCS key of the clustering stash JSON |
798
- | `language` | `str` | No | Target language code (default: `"en"`) |
825
+ | `language` | `str` | No | Output language as an app ISO code, e.g. `"de"` — not a locale like `"de-DE"`. Note the non-standard `cz` (Czech) and `gr` (Greek). (default: `"en"`) |
799
826
  | `callback_url` | `Optional[str]` | No | URL to POST results to after persistence |
800
827
  | `callback_api_key` | `Optional[str]` | No | Auth key for the callback |
801
828
  | `approved_topic_ids` | `Optional[list[str]]` | No | Filter to only these topic IDs from the stash (omit to persist all) |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sf-queue-sdk"
3
- version = "0.4.0b131"
3
+ version = "0.5.0"
4
4
  description = "Python SDK for sf-queue - Redis-based queue system"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  """sf-queue Python SDK - Redis-based queue system."""
2
2
 
3
- __version__ = "0.4.0b131"
3
+ __version__ = "0.5.0"
4
4
 
5
5
  from queue_sdk.client import QueueClient
6
6
  from queue_sdk.query import QueryClient
@@ -7,10 +7,32 @@ from typing import Any, Optional
7
7
 
8
8
  import redis as redis_lib
9
9
 
10
- from queue_sdk.redis_client import response_key, stream_name
10
+ from queue_sdk.redis_client import idempotency_key, response_key, stream_name
11
11
  from queue_sdk.types import EmailResponse
12
12
 
13
13
  DEFAULT_TIMEOUT = 30 # seconds
14
+ IDEMPOTENCY_TTL_SECONDS = 7 * 24 * 60 * 60
15
+ IDEMPOTENT_ENQUEUE_SCRIPT = """
16
+ local existing_request_id = redis.call("GET", KEYS[2])
17
+ if existing_request_id then
18
+ return {existing_request_id, 0}
19
+ end
20
+
21
+ redis.call(
22
+ "XADD",
23
+ KEYS[1],
24
+ "*",
25
+ "requestId",
26
+ ARGV[1],
27
+ "payload",
28
+ ARGV[2],
29
+ "createdAt",
30
+ ARGV[3]
31
+ )
32
+ redis.call("SET", KEYS[2], ARGV[1], "EX", ARGV[4])
33
+
34
+ return {ARGV[1], 1}
35
+ """
14
36
 
15
37
 
16
38
  class BaseQueue:
@@ -35,6 +57,35 @@ class BaseQueue:
35
57
  self._redis.xadd(self._stream, message)
36
58
  return request_id
37
59
 
60
+ def _enqueue_idempotently(
61
+ self, payload: dict[str, Any], caller_key: str
62
+ ) -> tuple[str, bool]:
63
+ """Add one stream entry for a caller key."""
64
+ if not caller_key.strip():
65
+ raise ValueError("idempotency_key must not be empty")
66
+
67
+ request_id = str(uuid.uuid4())
68
+ created_at = datetime.now(timezone.utc).isoformat()
69
+ result = self._redis.eval(
70
+ IDEMPOTENT_ENQUEUE_SCRIPT,
71
+ 2,
72
+ self._stream,
73
+ idempotency_key(self._environment, self._queue_name, caller_key),
74
+ request_id,
75
+ json.dumps(payload),
76
+ created_at,
77
+ IDEMPOTENCY_TTL_SECONDS,
78
+ )
79
+ if (
80
+ not isinstance(result, list)
81
+ or len(result) != 2
82
+ or not isinstance(result[0], str)
83
+ or result[1] not in (0, 1)
84
+ ):
85
+ raise RuntimeError("Redis returned an invalid idempotent enqueue result")
86
+
87
+ return result[0], result[1] == 1
88
+
38
89
  def _enqueue_and_wait(
39
90
  self, payload: dict[str, Any], timeout: Optional[int] = None
40
91
  ) -> EmailResponse:
@@ -52,6 +52,8 @@ class DocumentUploadQueue(BaseQueue):
52
52
  study_plan_hook: Optional[bool] = None,
53
53
  skip_material_creation: Optional[bool] = None,
54
54
  existing_material_id: Optional[str] = None,
55
+ params: Optional[dict] = None,
56
+ idempotency_key: Optional[str] = None,
55
57
  ) -> SendResult:
56
58
  """Enqueue a document upload processing request (fire and forget)."""
57
59
  self._validate(uploaded_file_id, study_set_id, user_id, pdf_s3_key, filename)
@@ -88,9 +90,18 @@ class DocumentUploadQueue(BaseQueue):
88
90
  study_plan_hook=study_plan_hook,
89
91
  skip_material_creation=skip_material_creation,
90
92
  existing_material_id=existing_material_id,
93
+ params=params,
91
94
  )
95
+ if idempotency_key is not None:
96
+ message_id, enqueued = self._enqueue_idempotently(payload, idempotency_key)
97
+ if enqueued:
98
+ self._set_initial_status(message_id)
99
+
100
+ return SendResult(message_id=message_id)
101
+
92
102
  message_id = self._enqueue(payload)
93
103
  self._set_initial_status(message_id)
104
+
94
105
  return SendResult(message_id=message_id)
95
106
 
96
107
  def get_status(self, request_id: str) -> DocumentUploadStatus:
@@ -174,6 +185,7 @@ class DocumentUploadQueue(BaseQueue):
174
185
  study_plan_hook: Optional[bool],
175
186
  skip_material_creation: Optional[bool],
176
187
  existing_material_id: Optional[str],
188
+ params: Optional[dict],
177
189
  ) -> dict[str, Any]:
178
190
  """Build the document upload payload (snake_case -> camelCase)."""
179
191
  payload: dict[str, Any] = {
@@ -227,6 +239,8 @@ class DocumentUploadQueue(BaseQueue):
227
239
  payload["skipMaterialCreation"] = skip_material_creation
228
240
  if existing_material_id is not None:
229
241
  payload["existingMaterialId"] = existing_material_id
242
+ if params is not None:
243
+ payload["params"] = params
230
244
 
231
245
  return payload
232
246
 
@@ -1,5 +1,6 @@
1
1
  """Redis connection factory."""
2
2
 
3
+ import hashlib
3
4
  from urllib.parse import urlparse
4
5
 
5
6
  import redis
@@ -45,6 +46,15 @@ def stream_name(env: str, base: str) -> str:
45
46
  return f"{env}:{base}"
46
47
 
47
48
 
49
+ def idempotency_key(env: str, queue: str, caller_key: str) -> str:
50
+ """Return a bounded key in the queue's Redis Cluster hash slot."""
51
+ digest = hashlib.sha256(caller_key.encode("utf-8")).hexdigest()
52
+ key = f"{{{queue}}}:idempotency:{digest}"
53
+ if not env:
54
+ return key
55
+ return f"{env}:{key}"
56
+
57
+
48
58
  def response_key(env: str, queue: str, request_id: str) -> str:
49
59
  """Return the response list key for a given queue and request ID."""
50
60
  key = f"{queue}:response:{request_id}"
@@ -173,6 +173,14 @@ class BatchRecommendationResponse:
173
173
  processed_at: Optional[str] = None
174
174
 
175
175
 
176
+ @dataclass
177
+ class DocumentUploadParams:
178
+ """Extensible flags for pipeline behavior."""
179
+
180
+ live_lecture: Optional[bool] = None
181
+ source: Optional[str] = None
182
+
183
+
176
184
  @dataclass
177
185
  class DocumentUploadData:
178
186
  """Data for a document upload processing request."""
@@ -206,6 +214,7 @@ class DocumentUploadData:
206
214
  callback_url: Optional[str] = None
207
215
  callback_api_key: Optional[str] = None
208
216
  study_plan_hook: Optional[bool] = None
217
+ params: Optional[DocumentUploadParams] = None
209
218
 
210
219
 
211
220
  @dataclass
@@ -0,0 +1,109 @@
1
+ """Focused idempotent enqueue tests."""
2
+
3
+ import unittest
4
+
5
+ from queue_sdk.queues.document_upload_queue import DocumentUploadQueue
6
+
7
+
8
+ class FakeRedis:
9
+ """Redis test double for the idempotent enqueue script."""
10
+
11
+ def __init__(self) -> None:
12
+ self.idempotency: dict[str, str] = {}
13
+ self.entries: list[dict[str, str]] = []
14
+ self.status_writes = 0
15
+
16
+ def eval(
17
+ self,
18
+ _script: str,
19
+ key_count: int,
20
+ stream: str,
21
+ deduplication_key: str,
22
+ request_id: str,
23
+ payload: str,
24
+ created_at: str,
25
+ ttl_seconds: int,
26
+ ) -> list[str | int]:
27
+ if key_count != 2:
28
+ raise AssertionError("expected two Redis keys")
29
+ if "{document-upload}" not in stream:
30
+ raise AssertionError("stream is missing the queue hash tag")
31
+ if "{document-upload}:idempotency:" not in deduplication_key:
32
+ raise AssertionError("idempotency key is in a different hash slot")
33
+ if ttl_seconds != 7 * 24 * 60 * 60:
34
+ raise AssertionError("unexpected idempotency key TTL")
35
+
36
+ existing_request_id = self.idempotency.get(deduplication_key)
37
+ if existing_request_id is not None:
38
+ return [existing_request_id, 0]
39
+
40
+ self.entries.append(
41
+ {
42
+ "stream": stream,
43
+ "request_id": request_id,
44
+ "payload": payload,
45
+ "created_at": created_at,
46
+ }
47
+ )
48
+ self.idempotency[deduplication_key] = request_id
49
+
50
+ return [request_id, 1]
51
+
52
+ def hset(self, *_args: object, **_kwargs: object) -> None:
53
+ self.status_writes += 1
54
+
55
+ def expire(self, *_args: object, **_kwargs: object) -> None:
56
+ pass
57
+
58
+
59
+ def send_upload(
60
+ queue: DocumentUploadQueue, idempotency_key: str
61
+ ):
62
+ """Send the minimum valid document upload payload."""
63
+ return queue.send(
64
+ uploaded_file_id="file-1",
65
+ study_set_id="set-1",
66
+ user_id="user-1",
67
+ text_extract_s3_key="text/key",
68
+ pdf_s3_key="pdf/key",
69
+ file_type="application/pdf",
70
+ effective_file_type="application/pdf",
71
+ filename="notes.pdf",
72
+ material_title="Notes",
73
+ url="https://example.com/notes.pdf",
74
+ generate_notes=True,
75
+ idempotency_key=idempotency_key,
76
+ )
77
+
78
+
79
+ class IdempotentDocumentUploadTests(unittest.TestCase):
80
+ """Document upload idempotency contract."""
81
+
82
+ def setUp(self) -> None:
83
+ self.redis = FakeRedis()
84
+ self.queue = DocumentUploadQueue(self.redis, "staging")
85
+
86
+ def test_duplicate_key_enqueues_once_and_returns_one_request_id(self) -> None:
87
+ first = send_upload(self.queue, "verified-upload-1")
88
+ duplicate = send_upload(self.queue, "verified-upload-1")
89
+
90
+ self.assertEqual(duplicate.message_id, first.message_id)
91
+ self.assertEqual(len(self.redis.entries), 1)
92
+ self.assertEqual(self.redis.status_writes, 1)
93
+
94
+ def test_different_keys_create_different_stream_entries(self) -> None:
95
+ first = send_upload(self.queue, "verified-upload-1")
96
+ second = send_upload(self.queue, "verified-upload-2")
97
+
98
+ self.assertNotEqual(second.message_id, first.message_id)
99
+ self.assertEqual(len(self.redis.entries), 2)
100
+
101
+ def test_empty_key_is_rejected(self) -> None:
102
+ with self.assertRaisesRegex(ValueError, "idempotency_key must not be empty"):
103
+ send_upload(self.queue, " ")
104
+
105
+ self.assertEqual(len(self.redis.entries), 0)
106
+
107
+
108
+ if __name__ == "__main__":
109
+ unittest.main()