fix(coordinator): force Tavily search for weather/realtime dialogue answers

Anchor weather and other realtime questions on runner /v1/search results (or an explicit refuse constraint) so the model cannot invent dates or temperatures from training memory.
This commit is contained in:
WeiCHEN
2026-09-23 14:51:09 +08:00
parent 1cb8c6f4b3
commit a5ea8197ab
12 changed files with 443 additions and 10 deletions
+96
View File
@@ -0,0 +1,96 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const piRunnerSearchTimeout = 20 * time.Second
// PiRunnerSearcher 通过 pi-agent runner 的 /v1/search 做 Tavily 检索。
type PiRunnerSearcher struct {
URL string
Token string
Client *http.Client
}
// Search 调用 runner /v1/search,返回 markdown 结果。
func (s *PiRunnerSearcher) Search(ctx context.Context, query string) (string, error) {
if s == nil {
return "", fmt.Errorf("pi runner searcher is nil")
}
base := strings.TrimRight(strings.TrimSpace(s.URL), "/")
q := strings.TrimSpace(query)
if base == "" {
return "", fmt.Errorf("pi runner url empty")
}
if q == "" {
return "", fmt.Errorf("search query empty")
}
body, err := json.Marshal(map[string]any{
"query": q,
"max_results": 5,
})
if err != nil {
return "", err
}
reqCtx, cancel := context.WithTimeout(ctx, piRunnerSearchTimeout)
defer cancel()
httpReq, err := http.NewRequestWithContext(reqCtx, http.MethodPost, base+"/v1/search", bytes.NewReader(body))
if err != nil {
return "", err
}
httpReq.Header.Set("Content-Type", "application/json")
ApplyRunnerAuth(httpReq, s.Token)
client := s.Client
if client == nil {
client = &http.Client{Timeout: piRunnerSearchTimeout}
}
resp, err := client.Do(httpReq)
if err != nil {
return "", err
}
defer resp.Body.Close()
limited, err := io.ReadAll(io.LimitReader(resp.Body, MaxResultBodyBytes+1))
if err != nil {
return "", err
}
if len(limited) > MaxResultBodyBytes {
return "", fmt.Errorf("search response too large")
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("search http %d", resp.StatusCode)
}
var parsed struct {
OK bool `json:"ok"`
Markdown string `json:"markdown"`
Error string `json:"error"`
}
if err := json.Unmarshal(limited, &parsed); err != nil {
return "", fmt.Errorf("decode search response: %w", err)
}
if !parsed.OK {
msg := strings.TrimSpace(parsed.Error)
if msg == "" {
msg = "search failed"
}
return "", fmt.Errorf("%s", msg)
}
md := strings.TrimSpace(parsed.Markdown)
if md == "" {
return "", fmt.Errorf("empty search markdown")
}
return md, nil
}
+69
View File
@@ -0,0 +1,69 @@
package agent
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestPiRunnerSearcher_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/search" {
t.Fatalf("path=%s", r.URL.Path)
}
if r.Header.Get("Authorization") != "Bearer tok-1" {
t.Fatalf("missing auth: %q", r.Header.Get("Authorization"))
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body["query"] != "今天北京天气" {
t.Fatalf("query=%v", body["query"])
}
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"markdown": "## 北京天气\n气温 21~33°C",
})
}))
defer srv.Close()
s := &PiRunnerSearcher{URL: srv.URL, Token: "tok-1", Client: srv.Client()}
md, err := s.Search(context.Background(), "今天北京天气")
if err != nil {
t.Fatalf("Search: %v", err)
}
if !strings.Contains(md, "21~33") {
t.Fatalf("markdown=%q", md)
}
}
func TestPiRunnerSearcher_HTTPFailure(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte(`{"ok":false,"error":"upstream"}`))
}))
defer srv.Close()
s := &PiRunnerSearcher{URL: srv.URL, Client: srv.Client()}
_, err := s.Search(context.Background(), "今天天气")
if err == nil {
t.Fatal("expected error")
}
}
func TestPiRunnerSearcher_EmptyMarkdown(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "markdown": " "})
}))
defer srv.Close()
s := &PiRunnerSearcher{URL: srv.URL, Client: srv.Client()}
_, err := s.Search(context.Background(), "新闻")
if err == nil {
t.Fatal("expected empty markdown error")
}
}
+6
View File
@@ -116,6 +116,12 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
}
dialogueBackend := agent.NewBackend(backendName, cfg)
coordEngine := coordinator.NewCoordinatorEngineWithDialogue(nil, dialogueBackend, cfg.DataDir)
if cfg != nil && strings.TrimSpace(cfg.PiAgentRunnerURL) != "" {
coordEngine.SetRealtimeSearcher(&agent.PiRunnerSearcher{
URL: cfg.PiAgentRunnerURL,
Token: cfg.PiAgentRunnerAuthToken,
})
}
parts := bizdev.Snapshot(svc)
if ctxmgr.SemanticSummaryShadowEnabled() && parts.Ctx != nil && backendName == "pi-agent" {
model := ""
+4
View File
@@ -222,6 +222,10 @@ type UserProjectContext struct {
UpgradeOffered bool `json:"-"`
// DocumentSummaryReader 仅用于服务端鉴权后的只读正文访问,不序列化。
DocumentSummaryReader DocumentSummaryReader `json:"-"`
// RealtimeSnippet 本轮实时检索(天气/新闻等)成功结果,供 Prompt 注入;不序列化。
RealtimeSnippet string `json:"-"`
// RealtimeUnavailable 本轮命中实时查询但检索失败/空结果,强制拒答约束;不序列化。
RealtimeUnavailable bool `json:"-"`
}
// ContextAssembler 上下文组装服务
+16
View File
@@ -58,6 +58,22 @@ func (e *CoordinatorEngine) generateDialogueReply(
return "", ReplySourceTemplate
}
if userCtx != nil {
userCtx.RealtimeSnippet = ""
userCtx.RealtimeUnavailable = false
if e.realtimeSearcher != nil && IsRealtimeInformationQuery(userInput) {
if st := stream.FromContext(ctx); st != nil {
st.EmitPhase("dialogue_realtime_search", "正在检索实时信息...", "assistant", 0, 0, 40)
}
md, searchErr := e.realtimeSearcher.Search(ctx, userInput)
if searchErr != nil || strings.TrimSpace(md) == "" {
userCtx.RealtimeUnavailable = true
} else {
userCtx.RealtimeSnippet = strings.TrimSpace(md)
}
}
}
prompt := buildDialoguePrompt(userInput, userCtx)
instructions := `你是「水木东方」医疗器械「项目助手」——温和、靠谱、会替用户着想的项目助理。
定位:以助理身份与用户友好协作。能力覆盖商务研判、工程方案与法规合规,但对外只以「项目助手」自称,不要分饰多角色,也不要自称「小队/参谋/总工/法规专家」。
+95
View File
@@ -393,3 +393,98 @@ func TestDialogueLLM_AmbiguousSmalltalkDoesNotUseStageSalesTemplate(t *testing.T
t.Fatalf("expected casual-chat template fallback, got: %s", res.NaturalReply)
}
}
type stubRealtimeSearcher struct {
markdown string
err error
calls int
lastQ string
}
func (s *stubRealtimeSearcher) Search(ctx context.Context, query string) (string, error) {
s.calls++
s.lastQ = query
return s.markdown, s.err
}
func TestDialogueLLM_RealtimeSearchInjectsSnippet(t *testing.T) {
backend := &scriptedDialogueBackend{reply: "依据检索:北京今日约 25°C。"}
tmp := t.TempDir()
engine := NewCoordinatorEngineWithDialogue(nil, backend, tmp)
searcher := &stubRealtimeSearcher{markdown: "## 北京天气\n气温 21~33°C"}
engine.SetRealtimeSearcher(searcher)
userCtx := &UserProjectContext{
Project: ProjectInfo{
ID: "p-rt-1",
Name: "全自动冲击波治疗仪",
CurrentStage: maturity.StageV01_Triage,
},
BlackboardFacts: map[string]string{},
}
rec := &intent.IntentRecognitionResult{
RawInput: "今天北京天气怎么样,出去见客户要准备什么吗",
Branch: intent.BranchNewIntentPreempt,
TopIntent: &intent.CandidateIntent{
Intent: intent.IntentCasualChat,
Confidence: 0.85,
Branch: intent.BranchNewIntentPreempt,
},
}
res, err := engine.ProcessIntent(context.Background(), rec, userCtx)
if err != nil {
t.Fatalf("ProcessIntent: %v", err)
}
if searcher.calls != 1 {
t.Fatalf("expected 1 search call, got %d", searcher.calls)
}
if !strings.Contains(backend.lastPrompt, "【实时检索结果】") {
t.Fatalf("prompt missing realtime section: %s", backend.lastPrompt)
}
if !strings.Contains(backend.lastPrompt, "21~33") {
t.Fatalf("prompt missing snippet: %s", backend.lastPrompt)
}
if res.ReplySource != ReplySourcePiAgent {
t.Fatalf("reply_source=%q", res.ReplySource)
}
}
func TestDialogueLLM_RealtimeSearchFailureInjectsRefuseConstraint(t *testing.T) {
backend := &scriptedDialogueBackend{reply: "抱歉,暂时无法获取实时天气数据,建议您查看天气 App。"}
tmp := t.TempDir()
engine := NewCoordinatorEngineWithDialogue(nil, backend, tmp)
searcher := &stubRealtimeSearcher{err: context.DeadlineExceeded}
engine.SetRealtimeSearcher(searcher)
userCtx := &UserProjectContext{
Project: ProjectInfo{
ID: "p-rt-2",
Name: "全自动冲击波治疗仪",
CurrentStage: maturity.StageV01_Triage,
},
BlackboardFacts: map[string]string{},
}
rec := &intent.IntentRecognitionResult{
RawInput: "今天北京天气怎么样",
Branch: intent.BranchNewIntentPreempt,
TopIntent: &intent.CandidateIntent{
Intent: intent.IntentCasualChat,
Confidence: 0.9,
Branch: intent.BranchNewIntentPreempt,
},
}
_, err := engine.ProcessIntent(context.Background(), rec, userCtx)
if err != nil {
t.Fatalf("ProcessIntent: %v", err)
}
if searcher.calls != 1 {
t.Fatalf("expected 1 search call, got %d", searcher.calls)
}
if strings.Contains(backend.lastPrompt, "【实时检索结果】") {
t.Fatalf("should not inject empty realtime section: %s", backend.lastPrompt)
}
if !strings.Contains(backend.lastPrompt, "本轮实时检索失败") {
t.Fatalf("missing refuse constraint: %s", backend.lastPrompt)
}
}
+18 -9
View File
@@ -65,15 +65,16 @@ type CoordinationResult struct {
// CoordinatorEngine L2 协调引擎
type CoordinatorEngine struct {
stageTasks StageTaskRunner
instSvc *projectinstance.Service
assembler *ContextAssembler
dialogueBackend agent.Backend
dataDir string
ctxMgr *ctxmgr.ContextManager
kbClient *kb.Client
impact ImpactActions
projectAccess ProjectAccess
stageTasks StageTaskRunner
instSvc *projectinstance.Service
assembler *ContextAssembler
dialogueBackend agent.Backend
realtimeSearcher RealtimeSearcher
dataDir string
ctxMgr *ctxmgr.ContextManager
kbClient *kb.Client
impact ImpactActions
projectAccess ProjectAccess
}
// StageTaskRunner is the application owner for ExecuteTask / ExecuteStage.
@@ -109,6 +110,14 @@ func (e *CoordinatorEngine) SetInstanceService(svc *projectinstance.Service) {
}
}
// SetRealtimeSearcher 注入对话路径的实时检索(天气/新闻等);nil 时跳过强制检索。
func (e *CoordinatorEngine) SetRealtimeSearcher(s RealtimeSearcher) {
if e != nil {
e.realtimeSearcher = s
}
}
// SetImpactActions wires read-only impact analysis and confirmed revision proposals.
func (e *CoordinatorEngine) SetImpactActions(a ImpactActions) {
if e != nil {
+18 -1
View File
@@ -230,6 +230,18 @@ func (mc *MemoryComposer) ComposeDialoguePrompt(userInput string, userCtx *UserP
sb.WriteString("\n")
}
// -------------------------------------------------------------
// 6b. 【实时检索结果】 天气/新闻等权威实时数据(强制锚定)
// -------------------------------------------------------------
if snip := strings.TrimSpace(userCtx.RealtimeSnippet); snip != "" {
sb.WriteString("【实时检索结果】(权威实时数据,回答天气/新闻等只准以此为准)\n")
sb.WriteString(snip)
if !strings.HasSuffix(snip, "\n") {
sb.WriteString("\n")
}
sb.WriteString("\n")
}
// -------------------------------------------------------------
// 7. 【用户偏好画像】 PersonalizedGuidance (~300 字)
// -------------------------------------------------------------
@@ -259,7 +271,12 @@ func (mc *MemoryComposer) ComposeDialoguePrompt(userInput string, userCtx *UserP
sb.WriteString("1. 优先级裁决:项目黑板核心事实 > 本轮用户输入 > 近期对话 > 早期摘要 > 检索片段。\n")
sb.WriteString("2. 事实与引用:涉及法规条款与标准时,优先依据【参考知识】与【项目基准与协同上下文】并沿用〔引用键〕格式;无把握时明确提示依据不足,严禁生造标准号。\n")
sb.WriteString("3. 隐私与跨用户边界:其他成员姓名与私人习惯仅限理解语境,禁止外泄复述。\n")
sb.WriteString("4. 答复目标:请结合上下文,以温和严谨的「项目助手」口吻作答,并写入 reply.md。")
sb.WriteString("4. 答复目标:请结合上下文,以温和严谨的「项目助手」口吻作答,并写入 reply.md。\n")
if userCtx.RealtimeUnavailable {
sb.WriteString("5. 本轮实时检索失败:必须明确告知无法获取实时数据(建议查看天气 App),严禁编造任何具体天气/新闻数值。\n")
} else if strings.TrimSpace(userCtx.RealtimeSnippet) != "" {
sb.WriteString("5. 实时数据:涉及天气/新闻等时效问题,必须严格依据【实时检索结果】作答,不得使用训练记忆中的过期数值。\n")
}
return sb.String()
}
@@ -133,3 +133,47 @@ func TestMemoryComposer_HierarchyAndPriority(t *testing.T) {
idxWorkMem, idxProject, idxSummary, idxRecent, idxRecalled, idxReference, idxGuidance, idxUser, idxReq)
}
}
func TestMemoryComposer_RealtimeSnippetAndUnavailable(t *testing.T) {
composer := coordinator.NewMemoryComposer()
fixed := time.Date(2026, 9, 23, 10, 41, 0, 0, time.FixedZone("CST", 8*3600))
composer.NowFn = func() time.Time { return fixed }
okCtx := &coordinator.UserProjectContext{
Project: coordinator.ProjectInfo{
ID: "proj_rt",
Name: "冲击波治疗仪",
CurrentStage: maturity.StageV01_Triage,
},
BlackboardFacts: map[string]string{},
RealtimeSnippet: "北京今日气温约 21~33°C,晴。",
}
okPrompt := composer.ComposeDialoguePrompt("今天北京天气怎么样", okCtx)
if !strings.Contains(okPrompt, "【实时检索结果】") {
t.Fatalf("missing realtime section: %s", okPrompt)
}
if !strings.Contains(okPrompt, "21~33") {
t.Fatalf("missing snippet body: %s", okPrompt)
}
if !strings.Contains(okPrompt, "必须严格依据【实时检索结果】作答") {
t.Fatalf("missing success constraint: %s", okPrompt)
}
failCtx := &coordinator.UserProjectContext{
Project: coordinator.ProjectInfo{
ID: "proj_rt",
Name: "冲击波治疗仪",
CurrentStage: maturity.StageV01_Triage,
},
BlackboardFacts: map[string]string{},
RealtimeUnavailable: true,
}
failPrompt := composer.ComposeDialoguePrompt("今天北京天气怎么样", failCtx)
if strings.Contains(failPrompt, "【实时检索结果】") {
t.Fatalf("should not render empty realtime section: %s", failPrompt)
}
if !strings.Contains(failPrompt, "本轮实时检索失败") {
t.Fatalf("missing failure constraint: %s", failPrompt)
}
}
+38
View File
@@ -0,0 +1,38 @@
package coordinator
import (
"context"
"strings"
"unicode/utf8"
"chat-agent-system/internal/intent"
)
// RealtimeSearcher 对话路径的实时信息检索(天气/新闻等);由 app 注入 Pi Runner /v1/search。
type RealtimeSearcher interface {
Search(ctx context.Context, query string) (markdown string, err error)
}
var realtimeInfoMarkers = []string{
"天气", "气温", "温度", "下雨", "下雪", "降雨", "降雪", "台风", "雾霾",
"天气预报", "气象", "风力", "体感",
"新闻", "头条", "股价", "汇率", "实时", "今日新闻", "热点新闻",
}
// IsRealtimeInformationQuery 判定是否为需要实时检索的时效性问句。
// 含强业务词典时不触发(避免「型检温度」「项目进度」等误伤)。
func IsRealtimeInformationQuery(input string) bool {
trimmed := strings.TrimSpace(input)
if trimmed == "" || utf8.RuneCountInString(trimmed) < 2 {
return false
}
if intent.HasStrongBusinessLexicon(trimmed) {
return false
}
for _, m := range realtimeInfoMarkers {
if strings.Contains(trimmed, m) {
return true
}
}
return false
}
@@ -0,0 +1,34 @@
package coordinator
import "testing"
func TestIsRealtimeInformationQuery(t *testing.T) {
positives := []string{
"今天北京天气怎么样",
"明天会下雨吗",
"外面气温多少",
"今天有什么新闻",
"美元汇率多少",
"查一下实时股价",
}
for _, in := range positives {
if !IsRealtimeInformationQuery(in) {
t.Fatalf("expected realtime query: %q", in)
}
}
negatives := []string{
"",
"a",
"项目进度怎么样",
"型检温度指标怎么定",
"注册路径需要注意什么",
"帮我推进 V0.5 方案",
"你好",
}
for _, in := range negatives {
if IsRealtimeInformationQuery(in) {
t.Fatalf("did not expect realtime query: %q", in)
}
}
}
+5
View File
@@ -182,6 +182,11 @@ func IsSmallTalkOrGeneralQuestion(input string) bool {
return false
}
// HasStrongBusinessLexicon 是否含强业务词典(项目/法规/文档治理等),供闲聊与实时检索判定复用。
func HasStrongBusinessLexicon(input string) bool {
return hasStrongBusinessLexicon(input)
}
func hasStrongBusinessLexicon(input string) bool {
markers := []string{
"项目", "阶段", "推进", "法规", "注册", "文档", "审核", "批准", "驳回",