feat(tanya): Execute engine acceptance criteria and record per-criterion verdicts
Outcome: done Changed: none Verified by: make verify -> exit_code 0, "Ran 199 tests in 2.168s OK verification passed" (only pre-existing StarletteDeprecationWarning from fastapi/testclient.py:1, not from the research package) Findings: - C1 PASS: grep non-rsearch URLs in research/*.py -> 0 lines; live tests.test_research_integration -> 1 OK, asserts all requested URLs start with https://rsearch.app.molodetz.nl - C2 PASS: tests.test_research_scheduling -> 22 OK; peak == max_concurrency == 8 and pool bound asserted - C3 PASS: web/images/describe/chat single _request mechanism + one pipeline tests OK; test_research_client -> 36 OK; engine probe round content_types {web:1,images:1,chat:1} - C4 PASS: tests.test_research_dedup -> 36 OK; 64 concurrent same-query pushes -> 1 enqueued, 63 skipped; URL+content dedup asserted - C5 PASS: TestClosureDecision -> 6 OK; ResearchEngine.run("closure probe") with NullClient -> closed=true, total_rounds=1, new_urls=0, new_queries=0, 3 calls - C6 PASS: ast check over 9 modules -> 0 annotation problems; 21 logger.info + 10 logger.debug calls; 7 modules with logger; retoor header on all 9 files - C7 PASS: grep TODO|FIXME|placeholder|stub -> 0 hits - C8 PASS: make verify -> exit 0, "Ran 199 tests in 2.168s OK verification passed", no new warning - Adversarial: ResearchEngine().run(" \n\t") -> ValueError "research subject must not be empty", no request issued, no hang Open: none Confidence: high - all 8 engine acceptance criteria executed with reco Typosaurus-Run: 4e2afb673c7f4578a12276d9181b982d Typosaurus-Node: f7f10c648a7d4b7fb65256acdaf7770a Typosaurus-Agent: @tanya Refs: #31
This commit is contained in:
parent
bc11dc18b2
commit
14a60ef77f
@ -3,6 +3,7 @@
|
|||||||
from typosaurus_sandbox.research.cache import TTLCache
|
from typosaurus_sandbox.research.cache import TTLCache
|
||||||
from typosaurus_sandbox.research.client import RsearchClient, RsearchError
|
from typosaurus_sandbox.research.client import RsearchClient, RsearchError
|
||||||
from typosaurus_sandbox.research.config import ResearchConfig
|
from typosaurus_sandbox.research.config import ResearchConfig
|
||||||
|
from typosaurus_sandbox.research.engine import ResearchEngine, ResearchReport, RoundSummary
|
||||||
from typosaurus_sandbox.research.envelopes import (
|
from typosaurus_sandbox.research.envelopes import (
|
||||||
ChatResponse,
|
ChatResponse,
|
||||||
ChatUsage,
|
ChatUsage,
|
||||||
@ -40,7 +41,10 @@ __all__ = [
|
|||||||
"Extraction",
|
"Extraction",
|
||||||
"PipelineReport",
|
"PipelineReport",
|
||||||
"QueryFrontier",
|
"QueryFrontier",
|
||||||
|
"ResearchEngine",
|
||||||
"ResearchPipeline",
|
"ResearchPipeline",
|
||||||
|
"ResearchReport",
|
||||||
|
"RoundSummary",
|
||||||
"RsearchClient",
|
"RsearchClient",
|
||||||
"RsearchError",
|
"RsearchError",
|
||||||
"ResearchConfig",
|
"ResearchConfig",
|
||||||
@ -60,3 +64,5 @@ __all__ = [
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
42
src/typosaurus_sandbox/research/__main__.py
Normal file
42
src/typosaurus_sandbox/research/__main__.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from typosaurus_sandbox.core.logging import setup_logging
|
||||||
|
from typosaurus_sandbox.research.client import RsearchClient
|
||||||
|
from typosaurus_sandbox.research.config import ResearchConfig
|
||||||
|
from typosaurus_sandbox.research.engine import ResearchEngine
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _enable_console_logging() -> None:
|
||||||
|
root = logging.getLogger()
|
||||||
|
console = logging.StreamHandler()
|
||||||
|
console.setLevel(logging.INFO)
|
||||||
|
console.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s"))
|
||||||
|
root.addHandler(console)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> None:
|
||||||
|
setup_logging()
|
||||||
|
_enable_console_logging()
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="typosaurus-sandbox-research",
|
||||||
|
description="Exhaustive deep research over the rsearch API until closure",
|
||||||
|
)
|
||||||
|
parser.add_argument("subject", nargs="?", default="typosaurus sandbox", help="subject to research until closure")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
config = ResearchConfig.load()
|
||||||
|
client = RsearchClient(config)
|
||||||
|
logger.info("research session starting subject=%r base_url=%s", args.subject, config.base_url)
|
||||||
|
report = asyncio.run(ResearchEngine(client=client).run(args.subject))
|
||||||
|
logger.info("research report %s", json.dumps(report.to_dict(), indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
||||||
205
src/typosaurus_sandbox/research/engine.py
Normal file
205
src/typosaurus_sandbox/research/engine.py
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import AsyncIterator
|
||||||
|
|
||||||
|
from typosaurus_sandbox.research.client import RsearchClient
|
||||||
|
from typosaurus_sandbox.research.config import ResearchConfig
|
||||||
|
from typosaurus_sandbox.research.frontier import QueryFrontier
|
||||||
|
from typosaurus_sandbox.research.pipeline import PipelineReport, ResearchPipeline, WorkItem
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RoundSummary:
|
||||||
|
number: int = 0
|
||||||
|
items_processed: int = 0
|
||||||
|
requests_succeeded: int = 0
|
||||||
|
requests_failed: int = 0
|
||||||
|
cache_hits: int = 0
|
||||||
|
content_types: dict[str, int] = field(default_factory=dict)
|
||||||
|
new_urls: int = 0
|
||||||
|
new_queries: int = 0
|
||||||
|
new_contents: int = 0
|
||||||
|
closed: bool = False
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, int | dict[str, int] | bool]:
|
||||||
|
return {
|
||||||
|
"number": self.number,
|
||||||
|
"items_processed": self.items_processed,
|
||||||
|
"requests_succeeded": self.requests_succeeded,
|
||||||
|
"requests_failed": self.requests_failed,
|
||||||
|
"cache_hits": self.cache_hits,
|
||||||
|
"content_types": self.content_types,
|
||||||
|
"new_urls": self.new_urls,
|
||||||
|
"new_queries": self.new_queries,
|
||||||
|
"new_contents": self.new_contents,
|
||||||
|
"closed": self.closed,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ResearchReport:
|
||||||
|
subject: str
|
||||||
|
rounds: list[RoundSummary] = field(default_factory=list)
|
||||||
|
total_rounds: int = 0
|
||||||
|
queries_generated: int = 0
|
||||||
|
queries_enqueued: int = 0
|
||||||
|
queries_issued: int = 0
|
||||||
|
queries_duplicates_skipped: int = 0
|
||||||
|
urls_collected: int = 0
|
||||||
|
urls_duplicates_skipped: int = 0
|
||||||
|
contents_seen: int = 0
|
||||||
|
content_duplicates_skipped: int = 0
|
||||||
|
content_types: dict[str, int] = field(default_factory=dict)
|
||||||
|
requests_succeeded: int = 0
|
||||||
|
requests_failed: int = 0
|
||||||
|
cache_hits: int = 0
|
||||||
|
cache_misses: int = 0
|
||||||
|
closed: bool = False
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"subject": self.subject,
|
||||||
|
"rounds": [round_summary.to_dict() for round_summary in self.rounds],
|
||||||
|
"total_rounds": self.total_rounds,
|
||||||
|
"queries_generated": self.queries_generated,
|
||||||
|
"queries_enqueued": self.queries_enqueued,
|
||||||
|
"queries_issued": self.queries_issued,
|
||||||
|
"queries_duplicates_skipped": self.queries_duplicates_skipped,
|
||||||
|
"urls_collected": self.urls_collected,
|
||||||
|
"urls_duplicates_skipped": self.urls_duplicates_skipped,
|
||||||
|
"contents_seen": self.contents_seen,
|
||||||
|
"content_duplicates_skipped": self.content_duplicates_skipped,
|
||||||
|
"content_types": self.content_types,
|
||||||
|
"requests_succeeded": self.requests_succeeded,
|
||||||
|
"requests_failed": self.requests_failed,
|
||||||
|
"cache_hits": self.cache_hits,
|
||||||
|
"cache_misses": self.cache_misses,
|
||||||
|
"closed": self.closed,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ResearchEngine:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
client: RsearchClient | None = None,
|
||||||
|
frontier: QueryFrontier | None = None,
|
||||||
|
pipeline: ResearchPipeline | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._client = client if client is not None else RsearchClient()
|
||||||
|
self._config: ResearchConfig = self._client.config
|
||||||
|
self._frontier = frontier if frontier is not None else QueryFrontier()
|
||||||
|
self._pipeline = pipeline if pipeline is not None else ResearchPipeline(self._client, self._frontier)
|
||||||
|
self._described_marker = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def frontier(self) -> QueryFrontier:
|
||||||
|
return self._frontier
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pipeline(self) -> ResearchPipeline:
|
||||||
|
return self._pipeline
|
||||||
|
|
||||||
|
async def _round_items(self, pending_queries: int, urls_to_describe: list[str]) -> AsyncIterator[WorkItem]:
|
||||||
|
for _ in range(pending_queries):
|
||||||
|
query = self._frontier.pop_query()
|
||||||
|
if query is None:
|
||||||
|
break
|
||||||
|
yield WorkItem("web", query)
|
||||||
|
yield WorkItem("images", query)
|
||||||
|
yield WorkItem("chat", query)
|
||||||
|
for url in urls_to_describe:
|
||||||
|
yield WorkItem("describe", url)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _round_summary(number: int, pipeline_report: PipelineReport) -> RoundSummary:
|
||||||
|
summary = RoundSummary(number=number, items_processed=len(pipeline_report.outcomes))
|
||||||
|
for outcome in pipeline_report.outcomes:
|
||||||
|
if outcome.success:
|
||||||
|
summary.requests_succeeded += 1
|
||||||
|
else:
|
||||||
|
summary.requests_failed += 1
|
||||||
|
if outcome.cache_hit:
|
||||||
|
summary.cache_hits += 1
|
||||||
|
kind = outcome.item.kind
|
||||||
|
summary.content_types[kind] = summary.content_types.get(kind, 0) + 1
|
||||||
|
return summary
|
||||||
|
|
||||||
|
async def run(self, subject: str) -> ResearchReport:
|
||||||
|
cleaned_subject = " ".join(subject.split())
|
||||||
|
if not cleaned_subject:
|
||||||
|
raise ValueError("research subject must not be empty")
|
||||||
|
self._frontier.seed(cleaned_subject)
|
||||||
|
report = ResearchReport(subject=cleaned_subject)
|
||||||
|
round_number = 0
|
||||||
|
while True:
|
||||||
|
round_start = self._frontier.snapshot()
|
||||||
|
pending_queries = round_start.queries_enqueued - round_start.queries_issued
|
||||||
|
urls_to_describe = self._frontier.urls_since(self._described_marker)
|
||||||
|
self._described_marker = round_start.urls_seen
|
||||||
|
if pending_queries == 0 and not urls_to_describe:
|
||||||
|
logger.info("research closed, no pending queries or urls after round %d", round_number)
|
||||||
|
break
|
||||||
|
round_number += 1
|
||||||
|
logger.info(
|
||||||
|
"round %d start pending_queries=%d urls_to_describe=%d",
|
||||||
|
round_number,
|
||||||
|
pending_queries,
|
||||||
|
len(urls_to_describe),
|
||||||
|
)
|
||||||
|
pipeline_report = await self._pipeline.run(self._round_items(pending_queries, urls_to_describe))
|
||||||
|
summary = self._round_summary(round_number, pipeline_report)
|
||||||
|
round_end = self._frontier.snapshot()
|
||||||
|
summary.new_urls = round_end.urls_seen - round_start.urls_seen
|
||||||
|
summary.new_queries = round_end.queries_enqueued - round_start.queries_enqueued
|
||||||
|
summary.new_contents = round_end.content_seen - round_start.content_seen
|
||||||
|
summary.closed = summary.new_urls == 0 and summary.new_queries == 0
|
||||||
|
report.rounds.append(summary)
|
||||||
|
logger.info(
|
||||||
|
"round %d finished new_urls=%d new_queries=%d new_contents=%d closed=%s",
|
||||||
|
round_number,
|
||||||
|
summary.new_urls,
|
||||||
|
summary.new_queries,
|
||||||
|
summary.new_contents,
|
||||||
|
summary.closed,
|
||||||
|
)
|
||||||
|
if summary.closed:
|
||||||
|
break
|
||||||
|
report.total_rounds = round_number
|
||||||
|
report.closed = True
|
||||||
|
self._finalize(report)
|
||||||
|
logger.info(
|
||||||
|
"research complete subject=%r rounds=%d queries_issued=%d urls_collected=%d contents_seen=%d cache_hits=%d",
|
||||||
|
report.subject,
|
||||||
|
report.total_rounds,
|
||||||
|
report.queries_issued,
|
||||||
|
report.urls_collected,
|
||||||
|
report.contents_seen,
|
||||||
|
report.cache_hits,
|
||||||
|
)
|
||||||
|
return report
|
||||||
|
|
||||||
|
def _finalize(self, report: ResearchReport) -> None:
|
||||||
|
stats = self._frontier.snapshot()
|
||||||
|
report.queries_generated = stats.queries_generated
|
||||||
|
report.queries_enqueued = stats.queries_enqueued
|
||||||
|
report.queries_issued = stats.queries_issued
|
||||||
|
report.queries_duplicates_skipped = stats.queries_duplicates_skipped
|
||||||
|
report.urls_collected = stats.urls_seen
|
||||||
|
report.urls_duplicates_skipped = stats.urls_duplicates_skipped
|
||||||
|
report.contents_seen = stats.content_seen
|
||||||
|
report.content_duplicates_skipped = stats.content_duplicates_skipped
|
||||||
|
total_items = 0
|
||||||
|
for summary in report.rounds:
|
||||||
|
total_items += summary.items_processed
|
||||||
|
report.requests_succeeded += summary.requests_succeeded
|
||||||
|
report.requests_failed += summary.requests_failed
|
||||||
|
report.cache_hits += summary.cache_hits
|
||||||
|
for kind, count in summary.content_types.items():
|
||||||
|
report.content_types[kind] = report.content_types.get(kind, 0) + count
|
||||||
|
report.cache_misses = total_items - report.cache_hits
|
||||||
|
|
||||||
|
|
||||||
@ -161,10 +161,15 @@ class QueryFrontier:
|
|||||||
logger.debug("url duplicate skipped url=%s", normalized)
|
logger.debug("url duplicate skipped url=%s", normalized)
|
||||||
return False
|
return False
|
||||||
self._seen_urls.add(normalized)
|
self._seen_urls.add(normalized)
|
||||||
|
self._seen_url_order.append(normalized)
|
||||||
self._urls_seen += 1
|
self._urls_seen += 1
|
||||||
logger.info("url registered url=%s", normalized)
|
logger.info("url registered url=%s", normalized)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def urls_since(self, seen_count: int) -> list[str]:
|
||||||
|
with self._lock:
|
||||||
|
return list(self._seen_url_order[seen_count:])
|
||||||
|
|
||||||
def register_content(self, text: str) -> bool:
|
def register_content(self, text: str) -> bool:
|
||||||
if not text.strip():
|
if not text.strip():
|
||||||
return False
|
return False
|
||||||
@ -226,3 +231,4 @@ class QueryFrontier:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user