squid-main-space / services /chat_runtime.py
mengxaingshuo's picture
fix: sync v4 runtime and fast inventory
dcea8ab
Raw
History Blame
41.6 kB
from __future__ import annotations
import os
from services.agent_firewall import server_management_refusal
# P0 chat runtime service.
# Dependencies are injected once from ui_server during app assembly so the
# large streaming/runtime logic is no longer owned by the entry module.
from services.context_compressor import (
build_request_spec_from_messages,
extract_request_spec,
merge_request_spec,
render_request_spec,
)
def _inject(context):
globals().update(context)
async def stream_chat(tid,prompt):
global last_llm_error
usage_user_id=thread_user_ids.get(tid,"")
pending_uploads=thread_upload_ids.pop(tid,[])
upload_context=""
if usage_user_id and pending_uploads:
upload_context=await build_user_upload_context(
usage_user_id,
pending_uploads,
)
if upload_context:
asyncio.create_task(
safe_memory_event(
usage_user_id,
"attachment_used",
{
"thread_id":tid,
"upload_ids":pending_uploads[:10],
"count":len(pending_uploads[:10]),
},
)
)
processing_context=""
if (
usage_user_id
and pending_uploads
and _quality_check_requested(prompt)
):
task_id="proc_"+secrets.token_hex(8)
await safe_memory_event(
usage_user_id,
"processing_started",
{
"task_id":task_id,
"thread_id":tid,
"operation":"quality_check",
"upload_ids":
pending_uploads[:10],
"status":"running",
},
)
yield out(
"status",
{
"text":
"正在使用本地 Python 检查上传数据…"
},
)
try:
processing_result=await asyncio.to_thread(
_run_upload_quality_checks,
usage_user_id,
pending_uploads,
)
record=_compact_processing_record(
processing_result
)
await safe_memory_event(
usage_user_id,
"processing_completed",
{
"task_id":task_id,
"thread_id":tid,
"operation":
"quality_check",
"upload_ids":
pending_uploads[:10],
"status":"completed",
"result":record,
},
)
processing_context=(
"[USER_DATA_PROCESSING_RESULT]\n"
"The following result was computed "
"locally with Python from the user's "
"uploaded file. Treat these computed "
"values as authoritative for this "
"answer. Do not estimate them from "
"the raw file. Explain the result "
"clearly in Chinese.\n"
+ json.dumps(
record,
ensure_ascii=False,
default=str,
)
+ "\n[/USER_DATA_PROCESSING_RESULT]"
)
yield out(
"status",
{
"text":
"数据质检完成,正在整理结果…"
},
)
except Exception as exc:
await safe_memory_event(
usage_user_id,
"processing_failed",
{
"task_id":task_id,
"thread_id":tid,
"operation":
"quality_check",
"upload_ids":
pending_uploads[:10],
"status":"failed",
"error":
str(exc)[:500],
},
)
raise
use_fisheries = _is_fisheries_prompt(prompt)
use_ocean = _needs_ocean_mcp(prompt)
mcp_error = None
turn_error = ""
export_tool_completed=False
export_tool_results=[]
try:
hf_task = None
if use_fisheries:
yield out("status",{"text":"正在读取 Hugging Face 渔业数据…","stage":"source","step":2,"total_steps":5})
hf_task = asyncio.create_task(build_hf_fisheries_context(prompt))
# Marine MCP is loaded by CodeWhale from DEEPSEEK_MCP_CONFIG when the
# runtime process starts. Do NOT bootstrap it by asking the model to
# call start_mcp_server inside every user thread: that creates a long
# blocking turn and can make the browser/HF proxy drop the SSE stream.
# A normal Ocean request goes straight to the real user turn; if the
# model needs Ocean data it can call the already-registered mcp_marine_*
# tools directly.
if use_ocean:
yield out("status",{"text":"Ocean 数据工具已就绪,正在处理请求…","stage":"source","step":2,"total_steps":5})
grounded_prompt = prompt
if processing_context:
grounded_prompt += (
"\n\n" + processing_context
)
elif upload_context:
grounded_prompt += (
"\n\n" + upload_context
)
if hf_task is not None:
try:
hf_context = await hf_task
grounded_prompt = grounded_prompt + "\n\n" + hf_context
if usage_user_id:
asyncio.create_task(
safe_memory_event(
usage_user_id,
"fisheries_query",
{
"thread_id":tid,
"source":"huggingface-fisheries",
"status":"completed",
"prompt_chars":len(prompt.strip()),
},
)
)
except Exception as exc:
grounded_prompt = (
prompt
+ "\n\n[HF_FISHERIES_LIVE_CONTEXT_ERROR]\n"
+ str(exc)
+ "\n[/HF_FISHERIES_LIVE_CONTEXT_ERROR]"
)
try:
det=await rjson(f"/v1/threads/{tid}")
since=int(det.get("latest_seq") or 0)
tr=await rjson(f"/v1/threads/{tid}/turns",method="POST",body={
"prompt":grounded_prompt,
"input_summary":prompt[:200],
"model":MODEL,
"mode":"agent",
"allow_shell":False,
"trust_mode":False,
"auto_approve":False,
})
except Exception as exc:
if not _is_codewhale_missing_thread_error(exc):
raise
history_text=_format_recent_thread_history(tid)
if history_text:
grounded_prompt=(
"[PRIOR_CONVERSATION_HISTORY]\n"
+ history_text
+ "\n[/PRIOR_CONVERSATION_HISTORY]\n\n"
+ "[CONVERSATION_RECOVERY_INSTRUCTION]\n"
+ "用户在本次对话中已经描述过下载/查询要求。上面是同一用户的完整对话记录;"
+ "请把最新用户消息当作该对话的后续补充或修改继续处理,"
+ "不要要求用户重新提交完整需求。\n"
+ "[/CONVERSATION_RECOVERY_INSTRUCTION]\n\n"
+ grounded_prompt
)
old_tid=tid
tid=await recover_codewhale_thread(old_tid)
log.warning(
"CodeWhale thread expired and was rebuilt: "
"old=%s new=%s",
old_tid,
tid,
)
yield out(
"status",
{
"text":
"检测到原对话会话已过期,正在自动保留上下文并恢复…",
"stage":"source",
"step":1,
"total_steps":5,
},
)
yield out(
"thread_recovered",
{
"thread_id":tid,
"old_thread_id":old_tid,
},
)
det=await rjson(f"/v1/threads/{tid}")
since=int(det.get("latest_seq") or 0)
tr=await rjson(f"/v1/threads/{tid}/turns",method="POST",body={
"prompt":grounded_prompt,
"input_summary":prompt[:200],
"model":MODEL,
"mode":"agent",
"allow_shell":False,
"trust_mode":False,
"auto_approve":False,
})
turn=((tr or {}).get("turn") or {}).get("id")
answer=""
yield out("status",{"text":"Codex 正在处理…","stage":"compose","step":4,"total_steps":5})
async for rec in events(tid,since):
if turn and rec.get("turn_id") and rec["turn_id"]!=turn:
continue
e=rec.get("event")
p=pl(rec)
if e=="item.started":
tool=_tool_name_from_payload(p)
if tool.startswith("mcp_marine_"):
yield out("status",{"text":"正在查询学校 Ocean 数据服务器…","stage":"query","step":3,"total_steps":5})
if usage_user_id:
asyncio.create_task(
safe_memory_event(
usage_user_id,
_marine_event_type(tool),
{
"thread_id":tid,
"tool":tool,
"operation":_marine_event_type(tool),
"source":_data_source_from_prompt(prompt),
"status":"started",
"prompt_chars":len(prompt.strip()),
},
)
)
if e=="item.completed":
item=p.get("item") or {}
summary=str(item.get("summary") or "")
completed_tool=_tool_name_from_payload(p)
try:
completed_raw=json.dumps(p,ensure_ascii=False,default=str)
except Exception:
completed_raw=str(p)
if (
completed_tool.endswith("_export")
or "mcp_marine_marine_export" in completed_raw
):
export_tool_completed=True
export_tool_results.append(completed_raw)
if "MCP server 'marine' connected" in summary or "mcp_marine_" in summary:
marine_threads.add(tid)
# Some Runtime versions can complete an agent_message item without
# delivering a delta to this bridge. Recover the materialized final text.
recovered=_agent_text_from_payload(p)
if recovered and not answer:
answer=recovered
if e=="item.delta" and p.get("kind")=="agent_message":
d=p.get("delta") or ""
if d:
answer+=d
if e in {"item.failed","item.interrupted"}:
err=_error_from_payload(p)
if err:
turn_error=err
if e=="approval.required":
aid=p.get("approval_id") or p.get("id")
tool=(
p.get("tool_name")
or ((p.get("tool") or {}).get("name") if isinstance(p.get("tool"),dict) else p.get("tool"))
or ((p.get("item") or {}).get("tool_name") if isinstance(p.get("item"),dict) else "")
or ""
)
if aid:
if tool.startswith("mcp_marine_"):
await approve(aid,"allow")
elif tool=="start_mcp_server":
# Static MCP config is authoritative. Never start another
# MCP server dynamically inside an end-user thread.
await approve(aid,"deny")
yield out("status",{"text":"已阻止重复启动 Ocean MCP"})
else:
await approve(aid,"deny")
yield out("status",{"text":"已保持安全数据访问模式"})
if e=="turn.lifecycle":
st=_turn_status(p)
if st in {"failed","canceled","interrupted"}:
detail=_error_from_payload(p) or turn_error or f"Turn {st}"
raise RuntimeError(detail)
if e=="turn.completed":
st=_turn_status(p)
if st in {"failed","canceled","interrupted"}:
detail=_error_from_payload(p) or turn_error or f"Turn {st}"
raise RuntimeError(detail)
if not answer.strip():
detail=_error_from_payload(p) or turn_error
if detail:
raise RuntimeError(detail)
raise RuntimeError(
"Codex 回合已结束,但 CodeWhale 没有产生 assistant 文本。"
)
final_answer=_sanitize_final_answer(answer)
if not final_answer:
raise RuntimeError("模型返回内容在输出清理后为空。")
export_error=_ocean_export_execution_error(
prompt,
export_tool_completed=export_tool_completed,
tool_result_text="\n".join(export_tool_results),
final_answer=final_answer,
)
if export_error:
raise RuntimeError(export_error)
if usage_user_id:
asyncio.create_task(
record_generated_download_assets(
usage_user_id,
tid,
prompt,
final_answer,
)
)
last_llm_error=None
yield out("token",{"text":final_answer})
yield out("done",{"text":final_answer})
return
raise RuntimeError(turn_error or "Runtime stream ended early")
except Exception as exc:
last_llm_error=str(exc)
log.exception(
"chat failed: thread=%s model=%s ocean=%s fisheries=%s",
tid, MODEL, use_ocean, use_fisheries,
)
yield out("error",{"text":_public_error(str(exc)),"stage":"chat"})
async def harness_stream_chat(tid,prompt):
global last_llm_error
usage_user_id=thread_user_ids.get(tid,"")
pending_uploads=thread_upload_ids.pop(tid,[])
upload_context=""
processing_context=""
if usage_user_id and pending_uploads:
upload_context=await build_user_upload_context(
usage_user_id,
pending_uploads,
)
asyncio.create_task(
safe_memory_event(
usage_user_id,
"attachment_used",
{
"thread_id":tid,
"upload_ids":pending_uploads[:10],
"count":len(pending_uploads[:10]),
"runtime":"codex-harness",
},
)
)
if (
usage_user_id
and pending_uploads
and _quality_check_requested(prompt)
):
task_id="proc_"+secrets.token_hex(8)
await safe_memory_event(
usage_user_id,
"processing_started",
{
"task_id":task_id,
"thread_id":tid,
"operation":"quality_check",
"upload_ids":pending_uploads[:10],
"status":"running",
"runtime":"codex-harness",
},
)
yield out(
"status",
{
"text":
"正在使用本地 Python 检查数据…"
},
)
try:
processing_result=await asyncio.to_thread(
_run_upload_quality_checks,
usage_user_id,
pending_uploads,
)
record=_compact_processing_record(
processing_result
)
await safe_memory_event(
usage_user_id,
"processing_completed",
{
"task_id":task_id,
"thread_id":tid,
"operation":"quality_check",
"upload_ids":pending_uploads[:10],
"status":"completed",
"runtime":"codex-harness",
"result":record,
},
)
processing_context=(
"[USER_DATA_PROCESSING_RESULT]\n"
"These values were computed locally "
"with Python from the current user's "
"uploaded file. Use them as the "
"authoritative result. Do not guess "
"or recompute them mentally. Explain "
"the result clearly in Chinese.\n"
+ json.dumps(
record,
ensure_ascii=False,
default=str,
)
+ "\n[/USER_DATA_PROCESSING_RESULT]"
)
yield out(
"status",
{
"text":
"数据计算完成,Codex Harness 正在整理结果…"
},
)
except Exception as exc:
await safe_memory_event(
usage_user_id,
"processing_failed",
{
"task_id":task_id,
"thread_id":tid,
"operation":"quality_check",
"upload_ids":pending_uploads[:10],
"status":"failed",
"runtime":"codex-harness",
"error":str(exc)[:500],
},
)
raise
app_context=thread_system_prompts.get(
tid,
USER_SYSTEM,
)
harness_prompt=(
"[APPLICATION_CONTEXT]\n"
+ app_context
+ "\n[/APPLICATION_CONTEXT]\n\n"
+ "[CURRENT_USER_MESSAGE]\n"
+ prompt
+ "\n[/CURRENT_USER_MESSAGE]"
)
if processing_context:
harness_prompt += (
"\n\n" + processing_context
)
elif upload_context:
harness_prompt += (
"\n\n" + upload_context
)
yield out(
"status",
{
"text":
f"Codex Harness · {HARNESS_MODEL} 正在处理…"
},
)
try:
task=asyncio.create_task(
asyncio.to_thread(
dsh.run,
harness_prompt,
session_id=tid,
)
)
while not task.done():
try:
await asyncio.wait_for(
asyncio.shield(task),
timeout=8,
)
except asyncio.TimeoutError:
yield ": keepalive\n\n"
result=await task
final_answer=_sanitize_final_answer(
result.final_response
)
if not final_answer:
raise RuntimeError(
"Codex Harness 没有返回有效文本。"
)
log.info(
"Codex Harness completed: "
"thread=%s model=%s reason=%s uploads=%s",
tid,
HARNESS_MODEL,
result.finish_reason,
len(pending_uploads),
)
last_llm_error=None
yield out(
"token",
{"text":final_answer},
)
yield out(
"done",
{
"text":final_answer,
"runtime":"codex-harness",
"model":HARNESS_MODEL,
"finish_reason":result.finish_reason,
},
)
except Exception as exc:
last_llm_error=str(exc)
log.exception(
"Codex Harness failed: "
"thread=%s model=%s uploads=%s",
tid,
HARNESS_MODEL,
len(pending_uploads),
)
yield out(
"error",
{
"text":
"Codex Harness 调用失败:"
+ str(exc)[:500],
"stage":"harness",
},
)
DATA_REQUEST_TTL_SECONDS = 6 * 3600
def _is_runtime_info_prompt(prompt: str) -> bool:
"""Recognize the read-only deployment-info question locally."""
text = str(prompt or "").strip().lower()
return (
("供应商" in text or "provider" in text)
and ("模型" in text or "model" in text)
and ("运行时" in text or "runtime" in text)
and not _needs_ocean_mcp(text)
and not _is_fisheries_prompt(text)
)
def _is_marine_health_prompt(prompt: str) -> bool:
"""Recognize a live Marine API status question without invoking the model."""
text = str(prompt or "").strip().lower()
health_terms = (
"海洋数据服务器连接", "检查海洋数据服务器", "数据服务器连接",
"三个数据域", "三个域是否在线", "ocean、tuna、squid",
"ocean, tuna, squid", "marine health", "marine server health",
)
return any(term in text for term in health_terms)
def _is_fisheries_inventory_prompt(prompt: str) -> bool:
"""Recognize file-list requests that do not need an agent turn.
A repository inventory is a read-only server operation. Sending this
request through the Codex CLI is both slow and, in headless deployments,
vulnerable to the interactive-MCP approval limitation. Keep actual file
reads, analysis and exports on their normal guarded paths.
"""
text = str(prompt or "").strip().lower()
# Be explicit about the wording used by the school/HF comparison tests.
# These requests mention storage provenance rather than a named source
# such as GFW or FAO, but they are still metadata-only Squid/Tuna queries.
explicit_inventory = (
any(term in text for term in ("完整文件数", "总大小", "本地镜像", "回退读取"))
and any(term in text for term in ("squid", "鱿鱼", "柔鱼", "tuna", "金枪鱼"))
)
if not _is_fisheries_prompt(text) and not explicit_inventory:
return False
inventory_terms = (
"可用数据集", "当前可用", "文件来源", "文件清单", "列出",
"有哪些文件", "入库", "目录", "inventory", "catalog",
)
if not any(term in text for term in inventory_terms) and not explicit_inventory:
return False
# “不读取文件内容” is explicitly a metadata-only request, rather than a
# request to open a file. Remove these negated phrases before testing for
# real content work.
metadata_text = text
for phrase in (
"不读取文件内容", "不读文件内容", "不读取内容", "不读内容",
"不读取文件", "不读文件", "无需读取", "不要读取",
):
metadata_text = metadata_text.replace(phrase, "")
content_terms = (
"字段", "记录数", "缺失", "重复", "筛选", "汇总", "聚合",
"导出", "下载", "实际读取", "读取csv", "读取 csv",
)
return not any(term in metadata_text for term in content_terms)
async def _direct_fisheries_inventory_answer(prompt: str) -> str:
"""Return a live fisheries file inventory without invoking the LLM/MCP."""
text = str(prompt or "").lower()
wants_tuna = any(term in text for term in ("tuna", "金枪鱼"))
wants_squid = any(term in text for term in ("squid", "鱿鱼", "柔鱼"))
domains = []
if wants_squid or not wants_tuna:
domains.append("squid")
if wants_tuna:
domains.append("tuna")
repo_map = globals().get("HF_DATASET_REPOS") or {}
tree_reader = globals().get("hf_live_tree")
files_only = globals().get("_hf_live_files")
human_bytes = globals().get("_human_bytes")
local_tree = globals().get("local_dataset_tree")
if not callable(tree_reader) or not callable(files_only) or not callable(human_bytes):
return "渔业数据清单服务尚未就绪。"
source_terms = {
"GFW": ("gfw", "global_fishing_watch", "global-fishing-watch"),
"SPRFMO": ("sprfmo",),
"NPFC": ("npfc",),
"WCPFC": ("wcpfc",),
"IATTC": ("iattc",),
"ICCAT": ("iccat",),
"IOTC": ("iotc",),
"CCSBT": ("ccsbt",),
"FAO": ("fao",),
"Sea Around Us": ("sea_around", "sea-around", "sea around"),
"RAM Legacy": ("ram",),
"VIIRS": ("viirs",),
}
lines = []
for domain in domains:
repo = str(repo_map.get(domain) or "").strip()
if not repo:
lines.append(f"- {domain.title()}:未配置数据仓库。")
continue
try:
raw_items = await tree_reader(repo)
files = files_only(raw_items)
except Exception as exc:
lines.append(f"- {domain.title()}:清单读取失败({str(exc)[:220]})。")
continue
total = sum(int(item.get("size_bytes") or 0) for item in files)
is_local = False
try:
is_local = bool(callable(local_tree) and local_tree(repo))
except Exception:
pass
origin = "学校服务器本地镜像" if is_local else "Hugging Face main 分支"
lines.extend([
f"## {domain.title()} 数据清单",
f"- 仓库:`{repo}`",
f"- 来源:{origin}",
f"- 可用文件:{len(files)} 个,约 {human_bytes(total)}",
])
groups = []
for name, terms in source_terms.items():
matched = [
item for item in files
if any(term in str(item.get("path") or "").lower() for term in terms)
]
if matched:
groups.append(
f"- {name}{len(matched)} 个文件,约 "
f"{human_bytes(sum(int(item.get('size_bytes') or 0) for item in matched))}"
)
if groups:
lines.append("- 按来源:")
lines.extend(groups)
else:
lines.append("- 按来源:当前文件路径未匹配到可识别来源标签。")
preview = files[:12]
if preview:
lines.append("- 文件示例:")
lines.extend(
f" - `{item.get('path')}`({human_bytes(item.get('size_bytes') or 0)})"
for item in preview
)
lines.append("- 本次仅读取文件清单元数据,未打开或分析文件内容。")
return "\n".join(lines) if lines else "未找到可查询的渔业数据域。"
async def _direct_marine_health_answer():
"""Query the configured Marine API directly for a deterministic status answer."""
httpx_module = globals().get("httpx")
if httpx_module is None:
try:
import httpx as httpx_module
except Exception as exc:
return f"海洋数据服务器连接检查失败:HTTP 客户端不可用({exc})"
base = str(globals().get("MARINE_API_URL") or "").rstrip("/")
if not base:
return "海洋数据服务器未配置 MARINE_API_URL。"
candidates = [f"{base}/health"]
# HF may expose the school API below the app's /marine reverse-proxy path.
if not base.endswith("/marine"):
candidates.append(f"{base}/marine/health")
last_error = ""
async with httpx_module.AsyncClient(timeout=12.0, follow_redirects=True) as client:
for url in candidates:
try:
response = await client.get(url)
if response.is_success:
payload = response.json()
domains = payload.get("domains") if isinstance(payload, dict) else None
labels = {
"ocean": "在线",
"tuna": "在线",
"squid": "在线",
}
if isinstance(domains, dict):
for key in labels:
value = domains.get(key)
if isinstance(value, dict):
value = value.get("status") or value.get("online")
if value in (False, "offline", "未连接", "不可用"):
labels[key] = "离线/不可用"
elif isinstance(domains, list):
for key in labels:
if key not in {str(item).lower() for item in domains}:
labels[key] = "未在健康响应中列出"
return (
"海洋数据服务器连接正常。\n"
f"- Ocean:{labels['ocean']}\n"
f"- Tuna:{labels['tuna']}\n"
f"- Squid:{labels['squid']}"
)
last_error = f"HTTP {response.status_code}"
except Exception as exc:
last_error = str(exc)
return f"海洋数据服务器连接检查失败:{last_error[:240]}"
def _context_spec_has_data(spec) -> bool:
if not isinstance(spec, dict):
return False
return any(
spec.get(key)
for key in (
"domain", "dataset", "variables", "region",
"depth", "date", "format",
)
)
async def dispatch_chat_stream(tid,prompt):
blocked = server_management_refusal(prompt)
if blocked:
yield out("token", {"text": blocked})
yield out(
"done",
{
"text": blocked,
"runtime": "server-management-firewall",
"model": "none",
"finish_reason": "blocked",
},
)
return
# Very short conversational greetings do not need a model turn or an MCP
# bootstrap. Keep data requests on the official Codex Harness path, but
# answer these deterministic prompts immediately so a simple "你好" does
# not pay the full CLI/network startup cost.
if _is_runtime_info_prompt(prompt):
provider = str(globals().get("CODEX_PROVIDER_NAME") or os.environ.get("CODEX_PROVIDER", "ZAI")).upper()
model = str(globals().get("HARNESS_MODEL") or os.environ.get("CODEX_MODEL", "glm-5.3"))
runtime = "官方 Codex CLI Harness(native)"
answer = f"- 供应商:{provider}\n- 模型名称:{model}\n- 运行时:{runtime}"
yield out("token", {"text": answer})
yield out("done", {"text": answer, "runtime": "local-fastpath", "model": model, "finish_reason": "completed"})
return
if _is_marine_health_prompt(prompt):
answer = await _direct_marine_health_answer()
yield out("token", {"text": answer})
yield out("done", {"text": answer, "runtime": "direct-marine-health", "model": "none", "finish_reason": "completed"})
return
if _is_fisheries_inventory_prompt(prompt):
# Never let a blocked HF connection or a malformed inventory response
# fall through to the 900-second Codex Harness timeout. Metadata-only
# inventory is deterministic and must always terminate promptly.
log.info("direct fisheries inventory fast path: prompt_chars=%s", len(str(prompt or "")))
try:
answer = await asyncio.wait_for(
_direct_fisheries_inventory_answer(prompt),
timeout=float(os.environ.get("FISHERIES_INVENTORY_TIMEOUT", "45")),
)
except asyncio.TimeoutError:
answer = (
"学校服务器的 Squid 清单查询超过 45 秒,已停止等待模型调用。"
"请稍后重试;本次没有读取文件内容,也没有生成文件。"
)
yield out("token", {"text": answer})
yield out(
"done",
{
"text": answer,
"runtime": "direct-fisheries-inventory",
"model": "none",
"finish_reason": "completed",
},
)
return
greeting = str(prompt or "").strip().lower()
# Keep short conversational greetings local. Variants such as
# "你好不好" must not start the full Codex/MCP network path.
greeting = greeting.replace("!", "!").replace("?", "?")
greeting_replies = {
"你好": "你好!有什么可以帮你的吗?",
"你好!": "你好!有什么可以帮你的吗?",
"你好?": "你好!有什么可以帮你的吗?",
"你好不好": "你好!我很好,谢谢关心。有什么可以帮你的吗?",
"你好吗": "你好!我很好,谢谢关心。有什么可以帮你的吗?",
"你好啊": "你好!有什么可以帮你的吗?",
"你好呀": "你好!有什么可以帮你的吗?",
"您好": "您好!有什么可以帮你的吗?",
"您好!": "您好!有什么可以帮你的吗?",
"您好?": "您好!有什么可以帮你的吗?",
"嗨": "你好!有什么可以帮你的吗?",
"hello": "Hello!有什么可以帮你的吗?",
"hi": "你好!有什么可以帮你的吗?",
"在吗": "在的,请告诉我你的海洋数据需求。",
}
if greeting in greeting_replies:
answer = greeting_replies[greeting]
yield out("token", {"text": answer})
yield out(
"done",
{
"text": answer,
"runtime": "local-fastpath",
"model": "none",
"finish_reason": "completed",
},
)
return
now=time.time()
pending=thread_last_data_requests.get(tid) or {}
if pending and now-float(pending.get("ts") or 0)>DATA_REQUEST_TTL_SECONDS:
thread_last_data_requests.pop(tid, None)
pending={}
history_spec=build_request_spec_from_messages(
thread_recent_history.get(tid) or []
)
context_spec=thread_compressed_contexts.get(tid) or {}
if not _context_spec_has_data(context_spec):
context_spec=history_spec
if not _context_spec_has_data(context_spec) and pending.get("spec"):
context_spec=pending.get("spec")
explicit_data=(
_needs_ocean_mcp(prompt)
or _is_fisheries_prompt(prompt)
)
is_confirmation=_is_confirmation_prompt(prompt)
is_amendment=bool(
(not is_confirmation)
and pending.get("prompt")
and _is_data_request_followup(prompt)
)
resumed=bool(
pending.get("prompt")
and (is_confirmation or is_amendment)
)
# Remember every data request and each short amendment that depends on it.
# This lets follow-ups such as “补充区域…”/“把格式改成 CSV” stay in the
# same data runtime and be merged with the original parameters instead of
# being treated as brand-new unrelated messages.
if is_amendment:
merged_spec=merge_request_spec(
context_spec,
prompt,
revision=True,
)
thread_compressed_contexts[tid]=merged_spec
thread_last_data_requests[tid]={
"prompt":render_request_spec(merged_spec),
"ts":now,
"spec":merged_spec,
}
elif explicit_data and not resumed:
parsed_spec=extract_request_spec(prompt)
if _context_spec_has_data(parsed_spec):
merged_spec=merge_request_spec(
None,
prompt,
revision=False,
)
thread_compressed_contexts[tid]=merged_spec
else:
merged_spec={"raw_request":str(prompt or "")[:1600]}
thread_compressed_contexts[tid]=merged_spec
thread_last_data_requests[tid]={
"prompt":render_request_spec(merged_spec) or prompt,
"ts":now,
"spec":merged_spec,
}
# Fast-path multi-day Ocean exports into a persistent batch job instead of
# keeping one Agent turn open for dozens of serial tool calls.
batch_spec=parse_ocean_batch_request(prompt)
if batch_spec:
if not resumed:
batch_spec_context=thread_compressed_contexts.get(tid) or {}
thread_last_data_requests[tid]={
"prompt":(
render_request_spec(batch_spec_context)
or prompt
),
"ts":now,
"spec":batch_spec_context,
}
uid=thread_user_ids.get(tid,"")
job=await OCEAN_BATCH_MANAGER.create(uid,prompt,batch_spec,tid)
yield out("status",{
"text":f"已拆分为 {job['total']} 个单日子任务,后台并行执行中…",
"stage":"query","step":3,"total_steps":5,
})
yield out("batch_job",job)
variables="、".join(batch_spec.get("variables") or [])
bbox=batch_spec.get("bbox") or {}
region_note=(
f"区域:{batch_spec.get('region_name')} "
f"({bbox.get('lon_min')}°E–{bbox.get('lon_max')}°E, "
f"{bbox.get('lat_min')}°N–{bbox.get('lat_max')}°N)。"
if batch_spec.get("region_name") else ""
)
text=(
f"已创建 Ocean 批量导出任务,共 {job['total']} 个单日子任务。"
f"范围 {batch_spec['start_date']}{batch_spec['end_date']},变量:{variables}。"
+ region_note +
"学校 Ocean 接口只接收单日 YYYY-MM-DD;平台已自动把月份/日期范围拆成单日并行执行,"
"不会再要求您补充某一天。任务卡会实时显示完成、失败、执行中和等待数量。"
)
yield out("done",{"text":text,"batch_job_id":job["job_id"]})
return
has_upload = bool(thread_upload_ids.get(tid))
compact_spec_text=render_request_spec(
thread_compressed_contexts.get(tid) or context_spec
)
routed_prompt=prompt
if is_confirmation and resumed:
thread_last_data_requests[tid]={
"prompt":compact_spec_text or str(pending["prompt"]),
"ts":now,
"spec":thread_compressed_contexts.get(tid) or context_spec,
}
routed_prompt=(
"请下载或继续执行以下已确认的数据请求,保留全部参数:\n\n"
+ (compact_spec_text or str(pending["prompt"]))
+ "\n\n[USER_CONFIRMATION]\n"
+ "用户刚刚回复确认。请立即继续执行上一项数据查询或导出,"
+ "沿用已经给出的日期、区域、变量和数据源,不要再次询问确认。\n"
+ "[/USER_CONFIRMATION]"
)
elif is_amendment:
routed_prompt=(
"请下载或继续执行以下数据请求,保留全部参数:\n\n"
+ str(
thread_last_data_requests[tid].get("prompt")
or pending["prompt"]
or ""
)
+ "\n\n[AMENDMENT_DIRECTIVE]\n"
+ "用户正在补充或修改上一项数据下载/查询请求。"
+ "请把“最近补充/修改”作为本次唯一变更,其余条件继续沿用卡片中的参数;"
+ "按最新要求立即执行,不要要求用户重新提交完整需求。\n"
+ "[/AMENDMENT_DIRECTIVE]"
)
routed_prompt=_apply_ocean_export_defaults(routed_prompt)
if _is_parse_only_request(prompt):
routed_prompt += (
"\n\n[READ_ONLY_PARAMETER_PARSE]\n"
"这是只解析参数的请求。禁止调用任何 Marine/MCP 数据工具,禁止查询、"
"导出、下载或创建任务。只返回从用户原话提取出的参数、缺失参数和校验结果。"
"[/READ_ONLY_PARAMETER_PARSE]"
)
# v4.3.2: never send a Harness thread to CodeWhale.
# When dsh is configured, CodeWhale is not a second required runtime.
# This fixes ordinary chat and prevents a data-related keyword from
# switching the user back to an unavailable localhost service.
keep_codewhale = dsh is None
if keep_codewhale:
async for chunk in stream_chat(tid, routed_prompt):
yield chunk
return
async for chunk in harness_stream_chat(tid, routed_prompt):
yield chunk
def init_chat_runtime(context):
_inject(context)
return stream_chat, harness_stream_chat, dispatch_chat_stream