agentic-thesis 0.7.0__py3-none-any.whl

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.
@@ -0,0 +1,29 @@
1
+ """Public AgenticThesis engine interface."""
2
+
3
+ from agentic_thesis.engine import AgenticThesisEngine
4
+ from agentic_thesis.models import (
5
+ ClaimDelta,
6
+ DeltaStatus,
7
+ DisclosureChunk,
8
+ DisclosureDocument,
9
+ EvidenceItem,
10
+ EvidencePack,
11
+ ReviewDecision,
12
+ ThesisClaim,
13
+ ThesisDelta,
14
+ ThesisSnapshot,
15
+ )
16
+
17
+ __all__ = [
18
+ "AgenticThesisEngine",
19
+ "ClaimDelta",
20
+ "DeltaStatus",
21
+ "DisclosureChunk",
22
+ "DisclosureDocument",
23
+ "EvidenceItem",
24
+ "EvidencePack",
25
+ "ReviewDecision",
26
+ "ThesisClaim",
27
+ "ThesisDelta",
28
+ "ThesisSnapshot",
29
+ ]
agentic_thesis/api.py ADDED
@@ -0,0 +1,596 @@
1
+ import asyncio
2
+ import hashlib
3
+ import json
4
+ import os
5
+ import urllib.parse
6
+ import urllib.request
7
+ from collections.abc import AsyncIterator
8
+ from contextlib import asynccontextmanager
9
+ from datetime import datetime, timezone
10
+ from importlib.resources import files
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from fastapi import FastAPI, HTTPException, Request
15
+ from fastapi.encoders import jsonable_encoder
16
+ from fastapi.responses import FileResponse, StreamingResponse
17
+ from dotenv import load_dotenv
18
+ from openai import AsyncOpenAI
19
+ from pydantic import BaseModel, Field, field_validator
20
+
21
+ from agentic_thesis.engine import AgenticThesisEngine
22
+ from agentic_thesis.models import (
23
+ DisclosureChunk,
24
+ DisclosureDocument,
25
+ ReviewDecision,
26
+ ThesisSnapshot,
27
+ )
28
+ from agentic_thesis.rag import OpenAIModel, chunk_filing
29
+ from agentic_thesis.workflow import AgenticThesisWorkflow
30
+
31
+
32
+ class StartRun(BaseModel):
33
+ run_id: str
34
+ thesis_id: str | None = None
35
+ thesis: ThesisSnapshot | None = None
36
+ chunks: list[DisclosureChunk] | None = None
37
+
38
+
39
+ class SecMonitorInput(BaseModel):
40
+ cik: str = Field(pattern=r"^\d{1,10}$")
41
+ forms: list[str] = Field(min_length=1, max_length=20)
42
+ enabled: bool = True
43
+
44
+ @field_validator("cik")
45
+ @classmethod
46
+ def normalize_cik(cls, value: str) -> str:
47
+ return value.zfill(10)
48
+
49
+ @field_validator("forms")
50
+ @classmethod
51
+ def normalize_forms(cls, values: list[str]) -> list[str]:
52
+ forms = list(dict.fromkeys(value.strip().upper() for value in values))
53
+ allowed = set("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-/")
54
+ if any(
55
+ not form or len(form) > 20 or not set(form) <= allowed
56
+ for form in forms
57
+ ):
58
+ raise ValueError("filing forms contain invalid characters")
59
+ return forms
60
+
61
+
62
+ class SecEdgarClient:
63
+ def __init__(self, user_agent: str) -> None:
64
+ self.user_agent = user_agent
65
+
66
+ def _read(self, url: str) -> bytes:
67
+ request = urllib.request.Request(
68
+ url,
69
+ headers={"User-Agent": self.user_agent, "Accept": "application/json,text/html"},
70
+ )
71
+ with urllib.request.urlopen(request, timeout=30) as response:
72
+ content = response.read(10_000_001)
73
+ if len(content) > 10_000_000:
74
+ raise ValueError("SEC response exceeds 10 MB")
75
+ return content
76
+
77
+ async def recent_filings(self, cik: str) -> list[dict[str, str]]:
78
+ payload = json.loads(
79
+ await asyncio.to_thread(
80
+ self._read,
81
+ f"https://data.sec.gov/submissions/CIK{cik}.json",
82
+ )
83
+ )
84
+ recent = payload["filings"]["recent"]
85
+ return [
86
+ {
87
+ "accession": accession,
88
+ "filing_date": recent["filingDate"][index],
89
+ "form": recent["form"][index],
90
+ "primary_document": recent["primaryDocument"][index],
91
+ }
92
+ for index, accession in enumerate(recent["accessionNumber"])
93
+ ]
94
+
95
+ async def filing_html(
96
+ self,
97
+ cik: str,
98
+ filing: dict[str, str],
99
+ ) -> tuple[str, str]:
100
+ accession = filing["accession"].replace("-", "")
101
+ document = urllib.parse.quote(filing["primary_document"], safe="")
102
+ url = f"https://www.sec.gov/Archives/edgar/data/{int(cik)}/{accession}/{document}"
103
+ content = await asyncio.to_thread(self._read, url)
104
+ return content.decode("utf-8", errors="ignore"), url
105
+
106
+
107
+ def _public_state(run_id: str, state: dict) -> dict:
108
+ timings = state.get("timings_ms", {})
109
+ return jsonable_encoder(
110
+ {
111
+ "run_id": run_id,
112
+ "status": state.get("status", "running"),
113
+ "thesis": state.get("thesis"),
114
+ "delta": state.get("delta"),
115
+ "evidence_packs": state.get("evidence_packs", []),
116
+ "timings_ms": timings,
117
+ "retrieval_timings_ms": state.get("retrieval_timings_ms", {}),
118
+ "total_ms": round(sum(timings.values()), 3),
119
+ "error": state.get("error"),
120
+ }
121
+ )
122
+
123
+
124
+ async def _default_engine() -> tuple[AgenticThesisEngine, ThesisSnapshot, list[DisclosureChunk]]:
125
+ data_dir = Path(
126
+ os.getenv("AGENTIC_THESIS_DATA_DIR", Path.home() / ".agentic-thesis")
127
+ ).expanduser()
128
+ data_dir.mkdir(parents=True, exist_ok=True)
129
+ load_dotenv(Path.cwd() / ".env")
130
+ load_dotenv(data_dir / ".env")
131
+ sample_data = files("agentic_thesis").joinpath("sample_data")
132
+ thesis = ThesisSnapshot.model_validate_json(
133
+ sample_data.joinpath("thesis_v1.json").read_text()
134
+ )
135
+ filings = [
136
+ (
137
+ "aapl-2023",
138
+ "2023-09-30",
139
+ "aapl-2023-10-k.html",
140
+ "https://www.sec.gov/Archives/edgar/data/320193/000032019323000106/aapl-20230930.htm",
141
+ ),
142
+ (
143
+ "aapl-2024",
144
+ "2024-09-28",
145
+ "aapl-2024-10-k.html",
146
+ "https://www.sec.gov/Archives/edgar/data/320193/000032019324000123/aapl-20240928.htm",
147
+ ),
148
+ ]
149
+ documents = {
150
+ accession: sample_data.joinpath("filings", filename).read_text(errors="ignore")
151
+ for accession, _, filename, _ in filings
152
+ }
153
+ chunks = [
154
+ chunk
155
+ for accession, filing_date, _, source_url in filings
156
+ for chunk in chunk_filing(
157
+ documents[accession],
158
+ accession=accession,
159
+ filing_date=filing_date,
160
+ source_url=source_url,
161
+ )
162
+ ]
163
+ model = OpenAIModel(
164
+ AsyncOpenAI(),
165
+ embedding_client=AsyncOpenAI(
166
+ api_key=os.environ["EMBEDDING_API_KEY"],
167
+ base_url=os.environ["EMBEDDING_BASE_URL"],
168
+ ),
169
+ model=os.getenv("AGENTIC_THESIS_MODEL", "gpt-5-mini"),
170
+ embedding_model=os.environ["AGENTIC_THESIS_EMBEDDING_MODEL"],
171
+ )
172
+ collection_name = "chunks_" + hashlib.sha256(
173
+ (
174
+ os.environ["EMBEDDING_BASE_URL"]
175
+ + "|"
176
+ + os.environ["AGENTIC_THESIS_EMBEDDING_MODEL"]
177
+ ).encode()
178
+ ).hexdigest()[:16]
179
+ engine = await AgenticThesisEngine.open_local(
180
+ data_dir,
181
+ embed=model.embed,
182
+ rerank=model.rerank,
183
+ analyze=model.analyze,
184
+ initial_chunks=chunks,
185
+ collection_name=collection_name,
186
+ )
187
+ await engine.create_thesis(thesis)
188
+ for accession, filing_date, _, source_url in filings:
189
+ await engine.add_disclosure(
190
+ DisclosureDocument(
191
+ document_id=accession,
192
+ thesis_id=thesis.thesis_id,
193
+ accession=accession,
194
+ filing_date=filing_date,
195
+ source_url=source_url,
196
+ content=documents[accession],
197
+ )
198
+ )
199
+ return engine, thesis, chunks
200
+
201
+
202
+ def create_app(
203
+ workflow: AgenticThesisEngine | AgenticThesisWorkflow | None = None,
204
+ *,
205
+ sec_client: Any = None,
206
+ monitor_interval: float | None = None,
207
+ collection_interval: float = 86_400,
208
+ ) -> FastAPI:
209
+ @asynccontextmanager
210
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
211
+ if workflow is None:
212
+ app.state.engine, app.state.thesis, app.state.chunks = await _default_engine()
213
+ app.state.workflow = app.state.engine._workflow
214
+ user_agent = os.getenv("AGENTIC_THESIS_SEC_USER_AGENT")
215
+ if app.state.sec_client is None and user_agent:
216
+ app.state.sec_client = SecEdgarClient(user_agent)
217
+ if app.state.monitor_interval is None:
218
+ app.state.monitor_interval = float(
219
+ os.getenv("AGENTIC_THESIS_SEC_POLL_SECONDS", "3600")
220
+ )
221
+ for run in await app.state.workflow.list_runs():
222
+ if run["status"] == "running":
223
+ app.state.run_conditions[run["run_id"]] = asyncio.Condition()
224
+ app.state.run_tasks[run["run_id"]] = asyncio.create_task(
225
+ execute_run(run["run_id"], resume=True)
226
+ )
227
+ if app.state.monitor_interval is not None:
228
+ app.state.monitor_task = asyncio.create_task(poll_sec_monitors())
229
+ yield
230
+ if app.state.monitor_task is not None:
231
+ app.state.monitor_task.cancel()
232
+ await asyncio.gather(app.state.monitor_task, return_exceptions=True)
233
+ pending = [task for task in app.state.run_tasks.values() if not task.done()]
234
+ for task in pending:
235
+ task.cancel()
236
+ await asyncio.gather(*pending, return_exceptions=True)
237
+ if workflow is None:
238
+ await app.state.engine.close()
239
+
240
+ app = FastAPI(title="AgenticThesis", version="0.7.0", lifespan=lifespan)
241
+ if workflow is not None:
242
+ app.state.engine = (
243
+ workflow
244
+ if isinstance(workflow, AgenticThesisEngine)
245
+ else AgenticThesisEngine(workflow)
246
+ )
247
+ app.state.workflow = app.state.engine._workflow
248
+ app.state.thesis = None
249
+ app.state.chunks = None
250
+ app.state.run_tasks = {}
251
+ app.state.run_conditions = {}
252
+ app.state.sec_client = sec_client
253
+ app.state.monitor_interval = monitor_interval
254
+ app.state.collection_interval = collection_interval
255
+ app.state.monitor_task = None
256
+
257
+ async def publish(run_id: str, event: dict) -> None:
258
+ event = jsonable_encoder(event)
259
+ condition = app.state.run_conditions.setdefault(run_id, asyncio.Condition())
260
+ async with condition:
261
+ await app.state.workflow.append_event(run_id, event)
262
+ condition.notify_all()
263
+
264
+ async def execute_run(
265
+ run_id: str,
266
+ thesis: ThesisSnapshot | None = None,
267
+ chunks: list[DisclosureChunk] | None = None,
268
+ *,
269
+ resume: bool = False,
270
+ ) -> None:
271
+ terminal = False
272
+ try:
273
+ updates = (
274
+ app.state.workflow.stream_resume(run_id)
275
+ if resume
276
+ else app.state.workflow.stream_start(run_id, thesis, chunks)
277
+ )
278
+ async for update in updates:
279
+ for node, payload in update.items():
280
+ if node == "__interrupt__":
281
+ await publish(
282
+ run_id,
283
+ {"node": "human_review", "status": "awaiting_review", "error": None},
284
+ )
285
+ terminal = True
286
+ continue
287
+ event = {
288
+ "node": node,
289
+ "status": "running",
290
+ "latency_ms": payload.get("timings_ms", {}).get(node),
291
+ "total_ms": round(sum(payload.get("timings_ms", {}).values()), 3),
292
+ "error": None,
293
+ }
294
+ if node == "retrieve_claims":
295
+ event["claims"] = [
296
+ {"claim_id": claim_id, **timings}
297
+ for claim_id, timings in payload.get(
298
+ "retrieval_timings_ms",
299
+ {},
300
+ ).items()
301
+ ]
302
+ if node == "build_evidence_packs":
303
+ event["claims"] = [
304
+ {
305
+ "claim_id": pack.claim_id,
306
+ "tokens_before": pack.tokens_before,
307
+ "tokens_after": pack.tokens_after,
308
+ }
309
+ for pack in payload.get("evidence_packs", [])
310
+ ]
311
+ await publish(run_id, event)
312
+ if not terminal:
313
+ state = await app.state.workflow.get(run_id)
314
+ await publish(
315
+ run_id,
316
+ {
317
+ "node": "workflow",
318
+ "status": state.get("status", "completed"),
319
+ "error": state.get("error"),
320
+ },
321
+ )
322
+ except asyncio.CancelledError:
323
+ raise
324
+ except Exception as exc:
325
+ error = str(exc)
326
+ await app.state.workflow.record_error(run_id, error)
327
+ await publish(
328
+ run_id,
329
+ {"node": "workflow", "status": "failed", "error": error},
330
+ )
331
+
332
+ async def launch_run(
333
+ run_id: str,
334
+ thesis: ThesisSnapshot,
335
+ chunks: list[DisclosureChunk],
336
+ ) -> None:
337
+ existing = app.state.run_tasks.get(run_id)
338
+ if existing and not existing.done():
339
+ raise HTTPException(status_code=409, detail="run is already active")
340
+ app.state.run_conditions[run_id] = asyncio.Condition()
341
+ if not await app.state.workflow.register_run(run_id, thesis):
342
+ raise HTTPException(status_code=409, detail="run already exists")
343
+ app.state.run_tasks[run_id] = asyncio.create_task(
344
+ execute_run(run_id, thesis, chunks)
345
+ )
346
+
347
+ @app.get("/", response_class=FileResponse)
348
+ async def product_page() -> FileResponse:
349
+ return FileResponse(Path(__file__).with_name("index.html"))
350
+
351
+ @app.post("/runs", status_code=202)
352
+ async def start_run(request: StartRun) -> dict:
353
+ if request.thesis_id:
354
+ thesis = await app.state.workflow.get_thesis(request.thesis_id)
355
+ if thesis is None:
356
+ raise HTTPException(status_code=404, detail="thesis not found")
357
+ chunks = await app.state.workflow.chunks_for_thesis(request.thesis_id)
358
+ if not chunks:
359
+ raise HTTPException(status_code=422, detail="thesis has no disclosures")
360
+ else:
361
+ thesis = request.thesis
362
+ if thesis is None and app.state.thesis is not None:
363
+ thesis = await app.state.workflow.current_snapshot(app.state.thesis)
364
+ chunks = request.chunks or app.state.chunks
365
+ if thesis is None or chunks is None:
366
+ raise HTTPException(status_code=422, detail="thesis and chunks are required")
367
+ await launch_run(request.run_id, thesis, chunks)
368
+ return {"run_id": request.run_id, "status": "running"}
369
+
370
+ @app.get("/theses")
371
+ async def list_theses() -> list[ThesisSnapshot]:
372
+ return await app.state.workflow.list_theses()
373
+
374
+ @app.post("/theses", status_code=201)
375
+ async def create_thesis(thesis: ThesisSnapshot) -> ThesisSnapshot:
376
+ if thesis.version != 1:
377
+ raise HTTPException(status_code=422, detail="a new thesis must start at version 1")
378
+ if not await app.state.engine.create_thesis(thesis):
379
+ raise HTTPException(status_code=409, detail="thesis already exists")
380
+ return thesis
381
+
382
+ @app.get("/theses/{thesis_id}")
383
+ async def get_thesis(thesis_id: str) -> ThesisSnapshot:
384
+ thesis = await app.state.workflow.get_thesis(thesis_id)
385
+ if thesis is None:
386
+ raise HTTPException(status_code=404, detail="thesis not found")
387
+ return thesis
388
+
389
+ @app.get("/monitors")
390
+ async def list_monitors() -> list[dict]:
391
+ return await app.state.workflow.list_sec_monitors()
392
+
393
+ @app.put("/theses/{thesis_id}/monitor")
394
+ async def configure_monitor(thesis_id: str, request: SecMonitorInput) -> dict:
395
+ if await app.state.workflow.get_thesis(thesis_id) is None:
396
+ raise HTTPException(status_code=404, detail="thesis not found")
397
+ return await app.state.workflow.configure_sec_monitor(
398
+ thesis_id,
399
+ request.cik,
400
+ request.forms,
401
+ request.enabled,
402
+ )
403
+
404
+ async def sync_sec_monitor(thesis_id: str) -> dict:
405
+ monitor = await app.state.workflow.get_sec_monitor(thesis_id)
406
+ if monitor is None:
407
+ raise HTTPException(status_code=404, detail="SEC monitor not configured")
408
+ if not monitor["enabled"]:
409
+ raise HTTPException(status_code=409, detail="SEC monitor is paused")
410
+ if app.state.sec_client is None:
411
+ raise HTTPException(
412
+ status_code=503,
413
+ detail="Set AGENTIC_THESIS_SEC_USER_AGENT to a name and contact email",
414
+ )
415
+ try:
416
+ filings = await app.state.sec_client.recent_filings(monitor["cik"])
417
+ matching = [item for item in filings if item["form"] in monitor["forms"]]
418
+ if monitor["last_accession"]:
419
+ accessions = [item["accession"] for item in matching]
420
+ candidates = (
421
+ matching[: accessions.index(monitor["last_accession"])]
422
+ if monitor["last_accession"] in accessions
423
+ else matching[:1]
424
+ )
425
+ else:
426
+ candidates = matching[:1]
427
+ run_ids = []
428
+ for filing in reversed(candidates):
429
+ content, source_url = await app.state.sec_client.filing_html(
430
+ monitor["cik"], filing
431
+ )
432
+ document = DisclosureDocument(
433
+ document_id=f"{thesis_id}:{filing['accession']}",
434
+ thesis_id=thesis_id,
435
+ accession=filing["accession"],
436
+ filing_date=filing["filing_date"],
437
+ source_url=source_url,
438
+ content=content,
439
+ )
440
+ chunks = chunk_filing(
441
+ content,
442
+ accession=document.accession,
443
+ filing_date=document.filing_date,
444
+ source_url=source_url,
445
+ )
446
+ if not chunks or not await app.state.workflow.add_disclosure(
447
+ document, chunks
448
+ ):
449
+ continue
450
+ thesis = await app.state.workflow.get_thesis(thesis_id)
451
+ corpus = await app.state.workflow.chunks_for_thesis(thesis_id)
452
+ run_id = f"sec-{thesis_id}-{filing['accession']}"
453
+ await launch_run(run_id, thesis, corpus)
454
+ run_ids.append(run_id)
455
+ await app.state.workflow.record_sec_sync(
456
+ thesis_id,
457
+ last_accession=matching[0]["accession"] if matching else None,
458
+ imported=len(run_ids),
459
+ )
460
+ except HTTPException:
461
+ raise
462
+ except Exception as exc:
463
+ await app.state.workflow.record_sec_sync(
464
+ thesis_id,
465
+ last_accession=None,
466
+ imported=0,
467
+ error=str(exc),
468
+ )
469
+ raise HTTPException(status_code=502, detail=f"SEC sync failed: {exc}") from exc
470
+ return {
471
+ "thesis_id": thesis_id,
472
+ "checked": len(matching),
473
+ "imported": len(run_ids),
474
+ "run_ids": run_ids,
475
+ }
476
+
477
+ async def poll_sec_monitors() -> None:
478
+ while True:
479
+ for monitor in await app.state.workflow.list_sec_monitors():
480
+ if not monitor["enabled"]:
481
+ continue
482
+ if monitor["last_checked_at"]:
483
+ checked_at = datetime.fromisoformat(
484
+ monitor["last_checked_at"]
485
+ ).replace(tzinfo=timezone.utc)
486
+ if (
487
+ datetime.now(timezone.utc) - checked_at
488
+ ).total_seconds() < app.state.collection_interval:
489
+ continue
490
+ try:
491
+ await sync_sec_monitor(monitor["thesis_id"])
492
+ except HTTPException:
493
+ pass
494
+ await asyncio.sleep(app.state.monitor_interval)
495
+
496
+ @app.post("/theses/{thesis_id}/sync")
497
+ async def sync_monitor(thesis_id: str) -> dict:
498
+ return await sync_sec_monitor(thesis_id)
499
+
500
+ @app.get("/disclosures")
501
+ async def list_disclosures(thesis_id: str) -> list[dict]:
502
+ return await app.state.workflow.list_disclosures(thesis_id)
503
+
504
+ @app.post("/disclosures", status_code=201)
505
+ async def create_disclosure(document: DisclosureDocument) -> dict:
506
+ if await app.state.workflow.get_thesis(document.thesis_id) is None:
507
+ raise HTTPException(status_code=404, detail="thesis not found")
508
+ try:
509
+ chunk_count = await app.state.engine.add_disclosure(document)
510
+ except ValueError as exc:
511
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
512
+ if chunk_count is None:
513
+ raise HTTPException(status_code=409, detail="disclosure already exists")
514
+ return {
515
+ "document_id": document.document_id,
516
+ "thesis_id": document.thesis_id,
517
+ "chunk_count": chunk_count,
518
+ }
519
+
520
+ @app.get("/runs")
521
+ async def list_runs(thesis_id: str | None = None) -> list[dict]:
522
+ return await app.state.workflow.list_runs(thesis_id)
523
+
524
+ @app.get("/runs/{run_id}")
525
+ async def get_run(run_id: str) -> dict:
526
+ state = await app.state.engine.get_run(run_id)
527
+ if not state:
528
+ raise HTTPException(status_code=404, detail="run not found")
529
+ return _public_state(run_id, state)
530
+
531
+ @app.get("/runs/{run_id}/events")
532
+ async def run_events(run_id: str, request: Request) -> StreamingResponse:
533
+ if await app.state.workflow.get_run(run_id) is None:
534
+ raise HTTPException(status_code=404, detail="run not found")
535
+
536
+ try:
537
+ after = int(request.headers.get("last-event-id", "0"))
538
+ except ValueError as exc:
539
+ raise HTTPException(status_code=400, detail="Last-Event-ID must be an integer") from exc
540
+
541
+ async def stream() -> AsyncIterator[str]:
542
+ sequence = after
543
+ condition = app.state.run_conditions.setdefault(run_id, asyncio.Condition())
544
+ while True:
545
+ async with condition:
546
+ events = await app.state.workflow.list_events(run_id, sequence)
547
+ if not events:
548
+ run = await app.state.workflow.get_run(run_id)
549
+ if run["status"] in {
550
+ "awaiting_review",
551
+ "committed",
552
+ "rejected",
553
+ "failed",
554
+ "version_conflict",
555
+ }:
556
+ return
557
+ await condition.wait()
558
+ continue
559
+ for sequence, event in events:
560
+ yield f"id: {sequence}\nevent: state\ndata: {json.dumps(event)}\n\n"
561
+ if event["status"] in {"awaiting_review", "committed", "rejected", "failed"}:
562
+ return
563
+
564
+ return StreamingResponse(stream(), media_type="text/event-stream")
565
+
566
+ @app.post("/runs/{run_id}/review")
567
+ async def review_run(run_id: str, decision: ReviewDecision) -> dict:
568
+ result = await app.state.engine.review(run_id, decision)
569
+ if result.get("status") == "version_conflict":
570
+ await publish(
571
+ run_id,
572
+ {
573
+ "node": "human_review",
574
+ "status": "version_conflict",
575
+ "error": result["error"],
576
+ },
577
+ )
578
+ raise HTTPException(status_code=409, detail=result["error"])
579
+ if result.get("status") == "review_conflict":
580
+ raise HTTPException(status_code=409, detail=result["error"])
581
+ if result.get("status") == "invalid_review":
582
+ raise HTTPException(status_code=422, detail=result["error"])
583
+ await publish(
584
+ run_id,
585
+ {
586
+ "node": "human_review",
587
+ "status": result.get("status", "completed"),
588
+ "error": result.get("error"),
589
+ },
590
+ )
591
+ return _public_state(run_id, result)
592
+
593
+ return app
594
+
595
+
596
+ app = create_app()
agentic_thesis/cli.py ADDED
@@ -0,0 +1,54 @@
1
+ import argparse
2
+ import os
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import uvicorn
7
+ from dotenv import load_dotenv
8
+
9
+
10
+ REQUIRED_CREDENTIALS = (
11
+ "OPENAI_API_KEY",
12
+ "EMBEDDING_API_KEY",
13
+ "EMBEDDING_BASE_URL",
14
+ "AGENTIC_THESIS_EMBEDDING_MODEL",
15
+ )
16
+
17
+
18
+ def _parser() -> argparse.ArgumentParser:
19
+ parser = argparse.ArgumentParser(prog="agentic-thesis")
20
+ commands = parser.add_subparsers(dest="command", required=True)
21
+ serve = commands.add_parser("serve", help="Start the AgenticThesis web application")
22
+ serve.add_argument(
23
+ "--data-dir",
24
+ type=Path,
25
+ default=Path.home() / ".agentic-thesis",
26
+ help="Persistent data directory (default: ~/.agentic-thesis)",
27
+ )
28
+ return parser
29
+
30
+
31
+ def main(argv: list[str] | None = None) -> int:
32
+ args = _parser().parse_args(argv)
33
+ data_dir = args.data_dir.expanduser().resolve()
34
+ load_dotenv(Path.cwd() / ".env")
35
+ load_dotenv(data_dir / ".env")
36
+ missing = [name for name in REQUIRED_CREDENTIALS if not os.getenv(name)]
37
+ if missing:
38
+ print("Missing required environment variables:", file=sys.stderr)
39
+ for name in missing:
40
+ print(f" - {name}", file=sys.stderr)
41
+ print(
42
+ f"Set them in the environment, ./.env, or {data_dir / '.env'}.",
43
+ file=sys.stderr,
44
+ )
45
+ return 2
46
+
47
+ data_dir.mkdir(parents=True, exist_ok=True)
48
+ os.environ["AGENTIC_THESIS_DATA_DIR"] = str(data_dir)
49
+ uvicorn.run("agentic_thesis.api:app", host="127.0.0.1", port=8000)
50
+ return 0
51
+
52
+
53
+ if __name__ == "__main__":
54
+ raise SystemExit(main())