Fix research context limits and preserve output correction audits
Development CI / check-and-build (push) Successful in 3m20s
GitHub Git mirror / mirror (push) Successful in 5s
Development CI / deploy-development (push) Successful in 2m29s

This commit is contained in:
eezd committed 2026-11-06 11:24:43 +08:00
1 parent 2cc5598ebd
commit 80a1aa389c
6 files changed
+233 -10

No files matched your search

+45
View File
@@ -246,3 +246,48 @@ def test_undelivered_source_is_not_restored_as_read(trading):
reader.restore([{'tool':'read_news','arguments':{'id':99},
'result':{'status':'context_limit'},'undelivered_result':{'article_id':-1,'content':'原文'}}])
assert not reader.sources and not reader.cache and not reader.provided_news
@pytest.mark.parametrize('context_window,body_length', [(8000, 7310), (250000, 249310)])
def test_parallel_tool_responses_leave_room_for_final_submission(trading, monkeypatch, context_window, body_length):
from alphapulse.news_research.tools import Research
from alphapulse.news_research import model
from alphapulse.ai.llm import Completion
from pydantic import BaseModel
class Result(BaseModel):
facts: str
reader = Research()
requests = []
tool_calls = [{'id': str(i), 'name': 'read_news', 'arguments': {'id': i}} for i in (1, 2)]
async def stream(provider, system, messages, tools, effort):
requests.append(messages.copy())
if len(requests) == 1:
yield Completion(tool_calls=tool_calls, usage={'input_tokens': 10})
else:
assert [t.name for t in tools] == ['submit']
assert reader.fits_messages(provider, system, messages, Result, final=True)
responses = [m for m in messages if m['role'] == 'tool']
assert [r['tool_call_id'] for r in responses] == ['1', '2']
assert all('context_limit' in r['result'] for r in responses)
yield Completion(tool_calls=[{'id': '3', 'name': 'submit', 'arguments': {'facts': '依据已有资料提交,补证不足。'}}],
usage={'input_tokens': 20})
async def tool(name, args):
assert args['id'] == 1
result = {'content': 'x' * body_length}
reader.sources[-1] = result
reader.provided_news[1] = -1
reader.records.append({'tool': name, 'arguments': args, 'result': result})
return result
monkeypatch.setattr(reader, 'tool', tool)
monkeypatch.setattr(model.llm, 'stream', stream)
provider = SimpleNamespace(model='test', reasoning_effort='medium', max_tokens=100, context_window=context_window)
output, usage = asyncio.run(reader.run(provider, '规则', {}, Result))
assert output.facts and len(requests) == len(usage['rounds']) == 2
assert not reader.sources and not reader.provided_news
assert len(reader.records) == 1
assert reader.records[0]['undelivered_result']['content'] == 'x' * body_length
@@ -685,6 +685,132 @@ def test_long_event_compaction_uses_final_research_context(trading, monkeypatch,
assert reader.fits(provider, skill, compressed, ResearchResult) != needs_compaction
@pytest.mark.parametrize("failure_at", [None, "evidence", "timeline", "publication"])
def test_quote_adjustment_audit_survives_validation_and_publication_failures(trading, monkeypatch, failure_at):
import asyncio
import copy
from unittest.mock import AsyncMock
from alphapulse.news_research import service
from alphapulse.news_research.contracts import ResearchResult
from alphapulse.news_research.models import Version
quote, original_quote = "官员“感到担忧”。", "官员“感到担忧” 。"
event = N(id=1, revision=1, title="官员回应", state="pending", result=None,
due_at=datetime.now(UTC) - timedelta(seconds=1))
news = N(id=101, title="官员回应", summary="", content_text=quote, content_hash="hash",
url="https://example.invalid/news", published_at=datetime.now(UTC))
package = N(id=7, news_id=101, state="done", content_hash="hash")
member = N(event_id=1, package_id=7, evidence=quote, reason="同一回应")
assignment = N(package_id=7, content_hash="hash", input={"keywords": [
{"canonical": "官员", "aliases": ["官员"], "quote": quote}]},
rules={"membership_targets": {"1": {"keyword_canonicals": ["官员"]}}})
output = ResearchResult(
id=1, title="官员回应", summary="官员表达担忧。", category="政策", entities=[],
risk_stars=1, importance_stars=2, direction="unknown", verification="reported", confidence="low",
reasoning="依据报道。", uncertainty="缺少独立佐证。", facts="官员表达担忧。", timeline="官员回应。",
asset_impacts="未知。", opinions="持续观察。", counter_evidence="未发现。", watchpoints="后续回应。",
claims=[{"statement": "官员表达担忧。", "verdict": "supported", "evidence_refs": [
{"article_id": 101, "quote": "不存在的引文" if failure_at == "evidence" else quote}]}],
evidence_refs=[{"article_id": 101, "quote": original_quote}],
timeline_points=[{"occurred_at": None, "time_precision": "unknown", "time_text": "",
"reported_at": None, "description": "官员表达担忧。", "verification": "supported",
"package_ids": [999 if failure_at == "timeline" else 7],
"evidence_refs": [{"article_id": 101, "quote": quote}]}], references=[])
original = output.model_dump(mode="json")
versions, saved_rules = [], {}
class Session:
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
def add(self, row):
row.id = 1
versions.append(row)
async def commit(self):
nonlocal saved_rules
if versions[0].state == "done" and failure_at == "publication":
raise RuntimeError("发布写入失败")
saved_rules = copy.deepcopy(versions[0].rules)
async def rollback(self):
# Rollback discards uncommitted JSON changes, as the real ORM does.
versions[0].rules = copy.deepcopy(saved_rules)
session = Session()
session.get = AsyncMock(side_effect=lambda model, *args, **kwargs: versions[0] if model is Version else event)
session.execute = AsyncMock(side_effect=[[(package, member, news)], N(all=lambda: [(101, "hash")])])
session.scalars = AsyncMock(return_value=[assignment])
session.scalar = AsyncMock(return_value=None)
monkeypatch.setattr(service, "SessionLocal", lambda: session)
monkeypatch.setattr(service, "model_config", AsyncMock(return_value=(
N(max_tokens=32000, context_window=250000), "研究规则", {"system_prompt": "研究规则"})))
monkeypatch.setattr(service.vocab, "snapshot", AsyncMock(return_value={}))
monkeypatch.setattr(service.Research, "run", AsyncMock(return_value=(output, {"input_tokens": 10})))
asyncio.run(service.process_event(1, {}))
version = versions[0]
assert version.state == ("failed" if failure_at else "done"), version.error
if failure_at:
assert {"evidence": "原文引用", "timeline": "未知新闻包", "publication": "发布写入失败"}[failure_at] in version.error
assert version.output["evidence_refs"][0]["quote"] == quote
assert version.rules["raw_output"] == original
assert version.rules["output_adjustments"] == [{
"path": ".evidence_refs.0.quote", "original": original_quote, "replacement": quote,
"reason": "unique_punctuation_whitespace_alignment"}]
assert version.rules["system_prompt"] == "研究规则"
assert news.content_text == quote and output.model_dump(mode="json") == original
@pytest.mark.parametrize("failure_at", [None, "validation", "publication"])
def test_alias_adjustment_audit_survives_index_failures(trading, monkeypatch, failure_at):
import asyncio
import copy
from unittest.mock import AsyncMock
from alphapulse.news_research import service
from alphapulse.news_research.contracts import IndexResult
from alphapulse.news_research.models import Attempt, Package
news = N(id=101, title="", summary="", content_text="插件调用单独计费", content_hash="hash",
url="https://example.invalid/news", published_at=datetime.now(UTC))
package = N(id=7, news_id=101, state="index_pending", content_hash="hash")
output = IndexResult(keywords=[{"canonical": "插件计费", "aliases": ["插件调用", "插件调用收费"],
"quote": "不存在的引文" if failure_at == "validation" else "插件调用单独计费"}])
original = output.model_dump(mode="json")
attempts, saved_rules = [], {}
class Session:
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
def add(self, row):
row.id = 1
attempts.append(row)
async def commit(self):
nonlocal saved_rules
if attempts[0].state == "done" and failure_at == "publication":
raise RuntimeError("发布写入失败")
saved_rules = copy.deepcopy(attempts[0].rules)
async def rollback(self):
attempts[0].rules = copy.deepcopy(saved_rules)
session = Session()
session.get = AsyncMock(side_effect=lambda model, *args, **kwargs:
attempts[0] if model is Attempt else package if model is Package else news)
session.execute = AsyncMock()
monkeypatch.setattr(service, "SessionLocal", lambda: session)
monkeypatch.setattr(service.vocab, "snapshot", AsyncMock(return_value={"entries": []}))
monkeypatch.setattr(service, "model_config", AsyncMock(return_value=(None, "索引规则", {})))
monkeypatch.setattr(service, "complete", AsyncMock(return_value=(output, {"input_tokens": 10})))
asyncio.run(service._process_package(7, {}, "index"))
attempt = attempts[0]
assert attempt.state == ("failed" if failure_at else "done"), attempt.error
if failure_at:
assert {"validation": "原文引用", "publication": "发布写入失败"}[failure_at] in attempt.error
assert attempt.rules["raw_output"] == original
assert attempt.rules["output_adjustments"] == [{
"path": "keywords.0.aliases", "original": ["插件调用", "插件调用收费"],
"replacement": ["插件调用"], "reason": "aliases_absent_from_source"}]
assert news.content_text == "插件调用单独计费" and output.model_dump(mode="json") == original
@pytest.mark.parametrize("task", ["index", "assign", "research"])
def test_model_config_sends_only_selected_task_and_records_exact_prompt(trading, monkeypatch, task):
import asyncio
+35
View File
@@ -36,3 +36,38 @@ def test_context_budget_includes_unicode_and_tool_schema(risk):
from types import SimpleNamespace
provider = SimpleNamespace(max_tokens=256, context_window=3500)
assert not risk.fits_context(provider, '只使用原文证据', {'content': '风'*1000}, IndexResult)
@pytest.mark.parametrize('context_window', [250000, 1000000])
def test_news_context_is_capped_at_250k_including_output(risk, monkeypatch, context_window):
from pydantic import BaseModel
from types import SimpleNamespace
from unittest.mock import AsyncMock
class Result(BaseModel):
facts: str
provider = SimpleNamespace(max_tokens=32000, context_window=context_window)
assert risk.fits_context(provider, '', {'content': 'x' * 200000}, Result)
stream = AsyncMock()
monkeypatch.setattr(risk.llm, 'stream', stream)
with pytest.raises(ValueError, match='超出模型上下文'):
asyncio.run(risk.complete(provider, '', {'content': 'x' * 220000}, Result))
stream.assert_not_called()
def test_research_reserves_tool_room_within_250k_cap(risk):
from alphapulse.news_research.tools import Research
from pydantic import BaseModel
from types import SimpleNamespace
class Result(BaseModel):
facts: str
reader = Research()
payload = {'content': 'x' * 190000}
for context_window in (250000, 1000000):
provider = SimpleNamespace(max_tokens=32000, context_window=context_window)
assert risk.fits_context(provider, '', payload, Result)
assert not reader.fits(provider, '', payload, Result)
assert provider.context_window == context_window