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.
- agentic_thesis/__init__.py +29 -0
- agentic_thesis/api.py +596 -0
- agentic_thesis/cli.py +54 -0
- agentic_thesis/engine.py +86 -0
- agentic_thesis/index.html +505 -0
- agentic_thesis/models.py +100 -0
- agentic_thesis/rag.py +522 -0
- agentic_thesis/sample_data/filings/aapl-2023-10-k.html +4 -0
- agentic_thesis/sample_data/filings/aapl-2024-10-k.html +8 -0
- agentic_thesis/sample_data/thesis_v1.json +28 -0
- agentic_thesis/workflow.py +641 -0
- agentic_thesis-0.7.0.dist-info/METADATA +350 -0
- agentic_thesis-0.7.0.dist-info/RECORD +17 -0
- agentic_thesis-0.7.0.dist-info/WHEEL +5 -0
- agentic_thesis-0.7.0.dist-info/entry_points.txt +2 -0
- agentic_thesis-0.7.0.dist-info/licenses/LICENSE +661 -0
- agentic_thesis-0.7.0.dist-info/top_level.txt +1 -0
|
@@ -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())
|