Bound research context and audit source-grounded output corrections
This commit is contained in:
1 parent
4f4db88333
commit
2cc5598ebd
6 files changed
+186
-22
No files matched your search
@@ -16,6 +16,7 @@ ASSIGN_SKILL = """你负责将一个完整新闻包归属到具体事件。外
|
||||
无论原文语言,生成的title、reason均用简体中文,主体名称沿用中文规范名;没有可靠中文名的专有名称、产品型号及通用缩写保留。quote逐字保留原语言,不翻译。keyword_canonicals必须逐字复用输入TAG,即使历史TAG是英文也不得在归属时翻译或重做索引。JSON键、event_id、new_key等标识及change_kind枚举值保持协议格式,不翻译。
|
||||
一个包可以关联多个事件:综述涉及多件独立事件时,各填一个归属,以对应连续原文quote说明相关部分,不能因主题相同而合并独立事件。
|
||||
事件以主体、具体事项和发生批次或统计期为边界;归属reason明确说明同一事实主线及原文依据。直接后果、回应、调查、更正、赔付属于原事件;独立决策或行动、新事故、新统计期另建事件,不能因为相关而强行合并。原文明示由某次数据发布引起的直接市场反应归入该次发布;独立政策决策另建。例:瑞士10月CPI环比、同比和受该次CPI影响的欧元兑瑞郎反应同属发布事件;瑞士11月CPI另建。币安同一次被盗及数月后追偿同属一事,第二次被盗另建。单次事故的资金追踪、冻结或追回即使由另一国家机关执行,也属于原事故直接处置,不能仅因执行主体或国家不同另建;只有原文明示独立案件范围(例如同一平台处理多起无关犯罪的执法)才另建并引用事故。关联不明时独立建事件并在reason说明缺口,不编造因果。
|
||||
已有事件尚未包含这次回应,不是另建事件的理由。例如同一次美国中期选举后,万斯解释败选原因、特朗普回应,应归入该次选举事件;正式宣布的新政策决定或独立行动另建事件。
|
||||
优先检查给定的长期事件候选;同一次事故、回应、调查、损失修订和处置可属于同一事件,即使横跨数月。仅主体相同不是同一事件。
|
||||
候选reporting_range是报道时间范围,evidence包含首条与近期成员原文线索,不等于事件发生时间;结合quote中的日期、统计期及具体事项区分每日数据、不同次事故等独立事件,不能仅因名称或TAG相同而归并,也不能仅因报道间隔长而拆开同一事件。
|
||||
每个target填写event_id或new_key且恰好一个非空、title、reason、quote、keyword_canonicals和change_kind、urgent。已有事件沿用ID;无合适候选则创建可独立识别的新事件名称,不编写事件总结。至少归属一个事件,单包事件也成立。title与quote只包含本事件事项,quote保留其主体、动作和对象;综述的其他事项不要混入。不能仅引用“下跌”“另有”等失去主体的片段;原文确实未具名时不补造。
|
||||
@@ -31,7 +32,8 @@ summary为事件总结;facts为已知事实;reasoning为判断依据;opini
|
||||
importance_stars独立评估:1背景、2局部、3重要、4广泛、5系统性。risk_stars:0无具体负面迹象、1关注、2警惕、3重大损害、4严重广泛冲击、5系统性危机。证据不充分降低confidence,不机械提高风险。verification=insufficient时direction=unknown。
|
||||
claims聚焦原文明示的关键主张、实质冲突及会改变判断的误读;不凭空增加“已获官方认证”“已决定全国控制权”等主张再机械判为不足。报道某人作出预测与预测已经实现分开。先核对数字、单位、期间和口径,保留算术反证能力。summary概括结论,reasoning说明推理,uncertainty集中写缺口,counter_evidence写实质反证或说明未发现,不在各字段重复整段限制。official仅表示核心主张得到实际读取的官方正文支持,不因有一个官方链接就覆盖其他未核实主张。
|
||||
每次站内查询先明确要解决的关键缺口:是否会改变事件身份、核心事实、核验状态、评级或判断。普通行情可直接按reported提交,不强查原因或收盘状态;重大事故、矛盾数字和关键政策优先通过已采集的站内原文核验。不得外部搜索、读取网页或访问URL。不为补齐字段查询;没有有效站内线索时提交,并集中说明证据局限。可独立进行的站内查询合并在同轮调用,不为每项claims各查一轮。
|
||||
articles已有成员完整原文时直接复用正文和文章id,不再逐包调用read_package/read_news,也不把重复读取当独立核验;仅输入extractions而无全文时,可按需读取对应成员原文。工具返回already_provided即使用先前已提供正文及article_id,不重试同一成员,也不继续逐个读取其他已提供成员。先列出会改变判断的最少证据缺口,将无需依赖彼此结果的站内查询合并同一轮;拿到线索后才执行依赖它的读取。每轮检查是否有新增有效证据;没有可解决关键缺口的新具体线索时立即submit,未解决项写uncertainty,不为凑核验轮次或空字段继续查询,不跳过重大事实所需的有效取证。
|
||||
articles已有成员完整原文时直接复用正文和文章id,不再逐包调用read_package/read_news,也不把重复读取当独立核验;仅输入extractions而无全文时,可按需读取对应成员原文。工具返回context_limit表示该资料未完整交付,不得引用该资料,停止查询并依据已有证据提交,将缺口写入uncertainty。检索结果仅用于定位,不能替代原文。
|
||||
工具返回already_provided即使用先前已提供正文及article_id,不重试同一成员,也不继续逐个读取其他已提供成员。先列出会改变判断的最少证据缺口,将无需依赖彼此结果的站内查询合并同一轮;拿到线索后才执行依赖它的读取。每轮检查是否有新增有效证据;没有可解决关键缺口的新具体线索时立即submit,未解决项写uncertainty,不为凑核验轮次或空字段继续查询,不跳过重大事实所需的有效取证。
|
||||
claims逐项核验,只有可能改变结论的缺口才查询站内新闻包、事件或经济日历。不机械耗尽预算;同稿转载和多个事件引用同一原文不是独立确认。URL不是已读证据,不得联网核验。站内已采集的官方原文可支持official;证据不足时明确缺口并据实降低核验状态和confidence,不编造补证。恢复客户资产余额不等于追回被盗资金;金额估值、可追踪比例须保留各自时点与来源。
|
||||
时间线timeline_points按实际发展组织,转载可共用一个节点,不强制每包一节点;成员包进展的节点填写package_ids及各包对应原文evidence_refs;packages.id是包ID,packages.news_id是文章ID,二者不能混用。仅由工具补充材料支撑的背景节点package_ids填空数组,不能冒用成员包ID。发生、计划、报道时间和统计期分开;未知为null/unknown。time_text逐字取自某条quote,不用报道时间替代发生时间。
|
||||
日和月精度的occurred_at/planned_at统一以UTC零点存储日期标记(日取当日、月取首日),它不是实际发生瞬间,不按事件所在国家猜时区;明确的当地日期保留其原日期,不先换算UTC日期。分钟精度仅在原文明确时区或可由具名交易场所明确确定时填写,否则保留time_text并填null/unknown。只有报道日期不能推定发生日期。季度及年度保留period_text,不伪造月或日精度。
|
||||
|
||||
@@ -54,6 +54,49 @@ def validate_evidence(refs, articles):
|
||||
if source is None or not ref.quote.strip() or not any(ref.quote in (source.get(k) or '') for k in ('title', 'summary', 'content')):
|
||||
raise ValueError('模型证据并非对应文章原文引用')
|
||||
|
||||
def align_quotes(value, articles):
|
||||
"""Recover unique original spans only when punctuation-adjacent whitespace differs."""
|
||||
punctuation = set(',。!?;:、,.;:!?“”‘’"\'()()[]【】')
|
||||
def normalized(text):
|
||||
chars, positions = [], []
|
||||
for match in re.finditer(r'\S|\s+', text):
|
||||
part = match.group()
|
||||
if part.isspace() and ((match.start() and text[match.start()-1] in punctuation)
|
||||
or (match.end() < len(text) and text[match.end()] in punctuation)):
|
||||
continue
|
||||
chars.extend(part)
|
||||
positions.extend(range(match.start(), match.end()))
|
||||
return ''.join(chars), positions
|
||||
changes = []
|
||||
def visit(item, path):
|
||||
if isinstance(item, list):
|
||||
for i, child in enumerate(item): visit(child, f'{path}.{i}')
|
||||
elif isinstance(item, dict):
|
||||
if 'article_id' in item and 'quote' in item:
|
||||
quote = item['quote']
|
||||
source = articles.get(item['article_id'], {})
|
||||
if not quote.strip() or any(quote in (source.get(k) or '') for k in ('title','summary','content')):
|
||||
return
|
||||
needle, _ = normalized(quote)
|
||||
matches = []
|
||||
if needle:
|
||||
for field in ('title','summary','content'):
|
||||
original = source.get(field) or ''
|
||||
haystack, positions = normalized(original)
|
||||
start = haystack.find(needle)
|
||||
while start >= 0:
|
||||
matches.append(original[positions[start]:positions[start+len(needle)-1]+1])
|
||||
start = haystack.find(needle, start+1)
|
||||
if len(matches) == 1 and len(matches[0]) <= 500:
|
||||
item['quote'] = matches[0]
|
||||
changes.append({'path':path+'.quote','original':quote,'replacement':matches[0],
|
||||
'reason':'unique_punctuation_whitespace_alignment'})
|
||||
else:
|
||||
for key, child in item.items(): visit(child, f'{path}.{key}')
|
||||
visit(value, '')
|
||||
return changes
|
||||
|
||||
|
||||
def validate_event(result, articles):
|
||||
if result.excluded_article_ids:
|
||||
raise ValueError('事件包含不相关文章:'+str(result.excluded_article_ids))
|
||||
|
||||
@@ -14,6 +14,7 @@ from alphapulse.news_research.pipeline import (
|
||||
validate_evidence,
|
||||
Evidence,
|
||||
validate_event,
|
||||
align_quotes,
|
||||
extract_articles,
|
||||
compact_extractions,
|
||||
)
|
||||
@@ -74,6 +75,22 @@ def validate_keywords(result, source):
|
||||
raise ValueError("关键词别名必须实际出现在本篇原文中")
|
||||
|
||||
|
||||
def clean_aliases(result, source):
|
||||
"""Retain every TAG; audit invalid aliases only when a valid alias remains."""
|
||||
cleaned = result.model_copy(deep=True)
|
||||
changes = []
|
||||
for i, keyword in enumerate(cleaned.keywords):
|
||||
valid = [alias for alias in keyword.aliases if alias.strip() and any(
|
||||
alias in (source.get(field) or '') for field in ('title','summary','content'))]
|
||||
if not valid:
|
||||
raise ValueError('关键词没有任何原文有效别名')
|
||||
if valid != keyword.aliases:
|
||||
changes.append({'path':f'keywords.{i}.aliases','original':keyword.aliases,
|
||||
'replacement':valid,'reason':'aliases_absent_from_source'})
|
||||
keyword.aliases = valid
|
||||
return cleaned, changes
|
||||
|
||||
|
||||
def validate_assignment(result, source, candidate_ids, keywords):
|
||||
for target in result.targets:
|
||||
if target.event_id is not None and target.event_id not in candidate_ids:
|
||||
@@ -462,7 +479,10 @@ async def _process_package(identifier, config, expected_stage):
|
||||
IndexResult if stage == "index" else Assignment,
|
||||
)
|
||||
if stage == "index":
|
||||
validate_keywords(output, source)
|
||||
cleaned, changes = clean_aliases(output, source)
|
||||
validate_keywords(cleaned, source)
|
||||
output = cleaned
|
||||
attempt.rules = {**attempt.rules, "output_adjustments": changes}
|
||||
else:
|
||||
validate_assignment(output, source, {e.id for e in catalog}, payload["keywords"])
|
||||
async with publication_lock:
|
||||
@@ -691,14 +711,15 @@ class Research(ResearchTools):
|
||||
.limit(20)
|
||||
)
|
||||
result = [
|
||||
{"id": r.id, "title": r.title, "tags": r.tags} for r in rows
|
||||
{"id": r.id, "title": r.title, "tags": [t for t in r.tags
|
||||
if any(term.lower() in t.lower() for term in terms[:6])]} for r in rows
|
||||
]
|
||||
else:
|
||||
# Retain the phrase in addition to the bounded search terms.
|
||||
condition, score = package_matching([query, *terms[:6]], vocabulary)
|
||||
phrase, _ = package_matching([query], vocabulary)
|
||||
rows = await session.execute(
|
||||
select(Package, NewsItem.content_hash)
|
||||
select(Package, NewsItem.content_hash, NewsItem.title)
|
||||
.join(NewsItem)
|
||||
.where(condition)
|
||||
.order_by(
|
||||
@@ -710,9 +731,13 @@ class Research(ResearchTools):
|
||||
.limit(20)
|
||||
)
|
||||
result = [
|
||||
{"id": r.id, "news_id": r.news_id,
|
||||
"keywords": r.keywords if r.content_hash == source_hash else []}
|
||||
for r, source_hash in rows
|
||||
{"id": r.id, "news_id": r.news_id, "title": title,
|
||||
"keywords": [{"canonical": k["canonical"], "aliases": [a for a in k["aliases"]
|
||||
if any(t.lower() in a.lower() for t in terms[:6])]}
|
||||
for k in r.keywords if any(t.lower() in word.lower()
|
||||
for t in terms[:6] for word in [k["canonical"], *k["aliases"]])]
|
||||
if r.content_hash == source_hash else []}
|
||||
for r, source_hash, title in rows
|
||||
]
|
||||
elif name == "read_package":
|
||||
row = await session.get(Package, arguments["id"])
|
||||
@@ -902,10 +927,13 @@ async def process_event(identifier, config, force=False):
|
||||
)
|
||||
if preparations:
|
||||
used = {"preparation": preparations, "research": used}
|
||||
validate_event(
|
||||
output,
|
||||
{**{a["id"]: a for a in payload["articles"]}, **researcher.sources},
|
||||
)
|
||||
sources = {**{a["id"]: a for a in payload["articles"]}, **researcher.sources}
|
||||
raw_output = output.model_dump(mode="json")
|
||||
changes = align_quotes(raw_output, sources)
|
||||
if changes:
|
||||
output = ResearchResult.model_validate(raw_output)
|
||||
version.rules = {**version.rules, "output_adjustments": changes}
|
||||
validate_event(output, sources)
|
||||
if output.id != identifier:
|
||||
raise ValueError("返回事件ID不匹配")
|
||||
package_news = {p.id: n.id for p, m, n in members}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Read-only news and external research tools with bounded calls."""
|
||||
import json
|
||||
import copy
|
||||
from sqlalchemy import select, or_
|
||||
from pydantic import BaseModel, Field
|
||||
from alphapulse.ai import llm
|
||||
@@ -47,18 +48,20 @@ class Research:
|
||||
def tool_specs():
|
||||
return [llm.ToolSpec(n, description, model.model_json_schema()) for n, (model, description) in TOOLS.items()]
|
||||
|
||||
def fits_messages(self, provider, system, messages, schema):
|
||||
def fits_messages(self, provider, system, messages, schema, *, final=False):
|
||||
return fits_context(provider, system, {'messages': messages, 'tools': [
|
||||
{'name': t.name, 'description': t.description, 'parameters': t.parameters} for t in self.tool_specs()]}, schema)
|
||||
{'name': t.name, 'description': t.description, 'parameters': t.parameters} for t in ([] if final else self.tool_specs())]}, schema)
|
||||
|
||||
def fits(self, provider, system, payload, schema):
|
||||
return self.fits_messages(provider, system, [{'role': 'user', 'text': json.dumps(payload, ensure_ascii=False)}], schema)
|
||||
reserved = copy.copy(provider)
|
||||
reserved.context_window -= min(32768, max(0, (provider.context_window-provider.max_tokens)//5))
|
||||
return self.fits_messages(reserved, system, [{'role': 'user', 'text': json.dumps(payload, ensure_ascii=False)}], schema)
|
||||
|
||||
def restore(self, records):
|
||||
for record in records:
|
||||
name, args, result = record['tool'], record['arguments'], record['result']
|
||||
if name not in TOOLS: continue
|
||||
if isinstance(result, dict) and 'error' in result: continue
|
||||
if isinstance(result, dict) and ('error' in result or result.get('status') == 'context_limit'): continue
|
||||
# Membership and revisions can change between attempts; reread local objects.
|
||||
if name in ('read_event', 'read_package'): continue
|
||||
self.cache[name+json.dumps(args, sort_keys=True)] = result
|
||||
@@ -121,12 +124,14 @@ class Research:
|
||||
messages = [{'role': 'user', 'text': json.dumps(payload, ensure_ascii=False)}]
|
||||
usages = self.usages = []
|
||||
from alphapulse.news_research.progress import step, current
|
||||
final_only = False
|
||||
for round_no in range(9):
|
||||
if current.get() is not None: step('模型研判', round=round_no+1, model=provider.model, reasoning_effort=provider.reasoning_effort)
|
||||
if not self.fits_messages(provider, system, messages, schema):
|
||||
final_only |= round_no == 8
|
||||
if not self.fits_messages(provider, system, messages, schema, final=final_only):
|
||||
raise ValueError('研究上下文不足,请增大上下文配置;证据未截断')
|
||||
completion = None
|
||||
async for item in model_stream(provider, system, messages, [*specs, submit] if round_no < 8 else [submit], provider.reasoning_effort):
|
||||
async for item in model_stream(provider, system, messages, [submit] if final_only else [*specs, submit], provider.reasoning_effort):
|
||||
if isinstance(item, llm.Completion): completion = item
|
||||
if completion is not None: usages.append(completion.usage)
|
||||
if completion is None or completion.truncated: raise ValueError('研究输出未完成或被截断')
|
||||
@@ -135,14 +140,28 @@ class Research:
|
||||
if len(calls) != 1: raise ValueError('提交结果不能同时执行查询')
|
||||
result = await validate_submission(provider, system, messages, schema, completion, usages)
|
||||
return result, {'rounds': usages, 'tools': self.records}
|
||||
if round_no == 8 or not calls: raise ValueError('研究模型未提交结果')
|
||||
if final_only or not calls: raise ValueError('研究模型未提交结果')
|
||||
assistant = {'role': 'assistant', 'text': completion.text, 'tool_calls': calls}
|
||||
if completion.thinking_blocks: assistant['thinking_blocks'] = completion.thinking_blocks
|
||||
messages.append(assistant)
|
||||
for call in calls:
|
||||
try: result = await self.tool(call['name'], call['arguments'])
|
||||
for index, call in enumerate(calls):
|
||||
snapshot = {name: copy.deepcopy(getattr(self, name)) for name in (
|
||||
'sources', 'provided_news', 'provided_packages', 'read_event_versions', 'read_event_ids')}
|
||||
records_before = len(self.records)
|
||||
try: result = await self.tool(call['name'], call['arguments']) if not final_only else {'status':'context_limit', 'instruction':'停止查询,依据已有证据提交并说明缺口。'}
|
||||
except Exception as error:
|
||||
result = {'error': str(error)}
|
||||
self.records.append({'tool': call['name'], 'arguments': call['arguments'], 'result': result})
|
||||
messages.append({'role': 'tool', 'tool_call_id': call['id'], 'result': json.dumps(result, ensure_ascii=False, default=str)})
|
||||
response = {'role':'tool', 'tool_call_id':call['id'], 'result':json.dumps(result, ensure_ascii=False, default=str)}
|
||||
remaining = [{'role':'tool', 'tool_call_id':c['id'], 'result':json.dumps({'status':'context_limit'})} for c in calls[index+1:]]
|
||||
if not self.fits_messages(provider, system, [*messages, response, *remaining], schema, final=True):
|
||||
for name, value in snapshot.items(): setattr(self, name, value)
|
||||
result = {'status':'context_limit', 'instruction':'资料未完整交付,不得引用;依据已有证据提交并说明缺口。'}
|
||||
for record in self.records[records_before:]:
|
||||
record['undelivered_result'] = record['result']
|
||||
record['result'] = result
|
||||
response['result'] = json.dumps(result, ensure_ascii=False)
|
||||
final_only = True
|
||||
messages.append(response)
|
||||
if not self.fits_messages(provider, system, messages, schema): final_only = True
|
||||
raise ValueError('研究轮次已用尽')
|
||||
Reference in new issue
Block a user