Explorar o código

Add original demand column

Sam Lee hai 3 semanas
pai
achega
9d42e7cfde

+ 41 - 0
content_agent/flow_ledger_service.py

@@ -119,6 +119,7 @@ class FlowLedgerService:
             "data_origin": self.data_origin,
             "data_origin": self.data_origin,
             "data_origin_label": "生产数据库" if self.data_origin == DATA_ORIGIN_PRODUCTION_DB else "本地调试缓存",
             "data_origin_label": "生产数据库" if self.data_origin == DATA_ORIGIN_PRODUCTION_DB else "本地调试缓存",
             "summary": self._summary(bundle, rows, extension_queries, media_counts, action_counts, gemini_counts),
             "summary": self._summary(bundle, rows, extension_queries, media_counts, action_counts, gemini_counts),
+            "demand_summary": _demand_summary(bundle),
             "rows": rows,
             "rows": rows,
             "extension_queries": extension_queries,
             "extension_queries": extension_queries,
             "debug": bundle if debug else None,
             "debug": bundle if debug else None,
@@ -1192,6 +1193,46 @@ def _demand_id(bundle: dict[str, Any]) -> str:
     return _text(source_context.get("demand_content_id") or source_context.get("id") or _evidence_pack(bundle).get("demand_content_id"))
     return _text(source_context.get("demand_content_id") or source_context.get("id") or _evidence_pack(bundle).get("demand_content_id"))
 
 
 
 
+def _demand_summary(bundle: dict[str, Any]) -> dict[str, Any]:
+    source_context = _record(bundle.get("source_context"))
+    raw = _record(source_context.get("raw_demand_content")) or source_context
+    ext_data = _record(raw.get("ext_data")) or _record(source_context.get("ext_data"))
+    evidence = _evidence_pack(bundle)
+    category_paths = _dedupe_texts([
+        _text(item.get("category_full_path") or item.get("category_path"))
+        for item in _list(evidence.get("category_bindings") or evidence.get("itemset_items"))
+    ])
+    extracted_points: list[str] = []
+    for binding in _list(evidence.get("element_bindings")):
+        for sample in _list(_record(binding).get("sample_elements")):
+            sample_record = _record(sample)
+            point_text = _text(sample_record.get("point_text") or sample_record.get("name"))
+            if not point_text:
+                continue
+            post_id = _text(sample_record.get("post_id"))
+            point_type = _text(sample_record.get("point_type"))
+            suffix = " / ".join(item for item in [point_type, f"帖子 {post_id}" if post_id else ""] if item)
+            extracted_points.append(f"{point_text}({suffix})" if suffix else point_text)
+    return {
+        "id": _demand_id(bundle),
+        "name": _text(raw.get("name") or source_context.get("name")),
+        "description": _text(ext_data.get("desc") or raw.get("suggestion") or source_context.get("suggestion")),
+        "reason": _text(ext_data.get("reason") or raw.get("reason") or source_context.get("reason")),
+        "source": _text(raw.get("merge_leve2") or source_context.get("merge_leve2")),
+        "score": raw.get("score") if raw.get("score") is not None else source_context.get("score"),
+        "date": _text(raw.get("dt") or source_context.get("dt")),
+        "type": _text(ext_data.get("type") or evidence.get("source_kind")),
+        "itemset_ids": _text_list(evidence.get("itemset_ids")),
+        "seed_terms": _text_list(evidence.get("seed_terms")),
+        "source_post_id": _text(evidence.get("source_post_id")),
+        "support": evidence.get("absolute_support") if evidence.get("absolute_support") is not None else evidence.get("support"),
+        "pattern_execution_id": _text(evidence.get("pattern_execution_id")),
+        "mining_config_id": _text(evidence.get("mining_config_id")),
+        "category_paths": category_paths,
+        "extracted_points": _dedupe_texts(extracted_points),
+    }
+
+
 def _source_post_id(seed_ref: dict[str, Any], source_ref: dict[str, Any], bundle: dict[str, Any]) -> str:
 def _source_post_id(seed_ref: dict[str, Any], source_ref: dict[str, Any], bundle: dict[str, Any]) -> str:
     return _text(
     return _text(
         source_ref.get("post_id")
         source_ref.get("post_id")

+ 1 - 0
content_agent/schemas.py

@@ -191,6 +191,7 @@ class FlowLedgerResponse(BaseModel):
     data_origin: str
     data_origin: str
     data_origin_label: str
     data_origin_label: str
     summary: dict[str, Any]
     summary: dict[str, Any]
+    demand_summary: dict[str, Any] | None = None
     rows: list[dict[str, Any]]
     rows: list[dict[str, Any]]
     extension_queries: list[dict[str, Any]]
     extension_queries: list[dict[str, Any]]
     debug: dict[str, Any] | None = None
     debug: dict[str, Any] | None = None

+ 45 - 0
tests/test_flow_ledger_api.py

@@ -17,10 +17,22 @@ def test_flow_ledger_api_returns_business_v4_ledger(tmp_path, monkeypatch):
         {
         {
             "run_id": run_id,
             "run_id": run_id,
             "demand_content_id": "demand_123",
             "demand_content_id": "demand_123",
+            "name": "睡前拉伸需求",
+            "suggestion": "用户希望看到睡前放松内容",
+            "reason": "来自 itemset 123",
+            "merge_leve2": "PG Pattern",
+            "score": 0.8,
+            "dt": "20260615",
             "ext_data": {
             "ext_data": {
+                "desc": "用户希望看到睡前放松内容",
+                "reason": "来自 itemset 123",
+                "type": "pattern",
                 "evidence_pack": {
                 "evidence_pack": {
                     "source_post_id": "post_001",
                     "source_post_id": "post_001",
                     "pattern_execution_id": 581,
                     "pattern_execution_id": 581,
+                    "itemset_ids": [1608101],
+                    "seed_terms": ["睡前拉伸"],
+                    "absolute_support": 33,
                     "category_bindings": [
                     "category_bindings": [
                         {
                         {
                             "category_id": 76006,
                             "category_id": 76006,
@@ -30,8 +42,35 @@ def test_flow_ledger_api_returns_business_v4_ledger(tmp_path, monkeypatch):
                             "itemset_item_id": 6055,
                             "itemset_item_id": 6055,
                         }
                         }
                     ],
                     ],
+                    "element_bindings": [
+                        {
+                            "itemset_id": 1608101,
+                            "category_id": 76006,
+                            "sample_elements": [
+                                {
+                                    "post_id": "post_001",
+                                    "point_text": "睡前肩颈放松",
+                                    "point_type": "目的点",
+                                }
+                            ],
+                        }
+                    ],
                 }
                 }
             },
             },
+            "raw_demand_content": {
+                "id": "demand_123",
+                "name": "睡前拉伸需求",
+                "suggestion": "用户希望看到睡前放松内容",
+                "reason": "来自 itemset 123",
+                "merge_leve2": "PG Pattern",
+                "score": 0.8,
+                "dt": "20260615",
+                "ext_data": {
+                    "desc": "用户希望看到睡前放松内容",
+                    "reason": "来自 itemset 123",
+                    "type": "pattern",
+                },
+            },
         },
         },
     )
     )
     service.runtime.write_json(
     service.runtime.write_json(
@@ -324,6 +363,12 @@ def test_flow_ledger_api_returns_business_v4_ledger(tmp_path, monkeypatch):
 
 
     ledger = client.get(f"/runs/{run_id}/flow-ledger").json()
     ledger = client.get(f"/runs/{run_id}/flow-ledger").json()
     assert ledger["data_origin_label"] == "本地调试缓存"
     assert ledger["data_origin_label"] == "本地调试缓存"
+    assert ledger["demand_summary"]["id"] == "demand_123"
+    assert ledger["demand_summary"]["name"] == "睡前拉伸需求"
+    assert ledger["demand_summary"]["description"] == "用户希望看到睡前放松内容"
+    assert ledger["demand_summary"]["itemset_ids"] == ["1608101"]
+    assert ledger["demand_summary"]["category_paths"] == ["/理念/观念/个人观念/人生观"]
+    assert ledger["demand_summary"]["extracted_points"] == ["睡前肩颈放松(目的点 / 帖子 post_001)"]
     assert len(ledger["rows"]) == 3
     assert len(ledger["rows"]) == 3
     assert ledger["summary"]["first_round_query_count"] == 3
     assert ledger["summary"]["first_round_query_count"] == 3
     assert ledger["summary"]["extension_query_count"] == 2
     assert ledger["summary"]["extension_query_count"] == 2

+ 97 - 5
web2/app/globals.css

@@ -355,10 +355,6 @@ a:focus-visible {
   text-align: center;
   text-align: center;
 }
 }
 
 
-.ledger-table thead tr:nth-child(2) th {
-  top: 33px;
-}
-
 .ledger-table tbody td {
 .ledger-table tbody td {
   font-size: 12px;
   font-size: 12px;
 }
 }
@@ -367,6 +363,7 @@ a:focus-visible {
   border-top: 1.5px solid var(--line-strong);
   border-top: 1.5px solid var(--line-strong);
 }
 }
 
 
+.group-demand,
 .group-source,
 .group-source,
 .sub-source {
 .sub-source {
   background: var(--source) !important;
   background: var(--source) !important;
@@ -400,6 +397,7 @@ a:focus-visible {
   background: var(--output-sub) !important;
   background: var(--output-sub) !important;
 }
 }
 
 
+.demand-cell,
 .source-cell {
 .source-cell {
   background: #f8fafc;
   background: #f8fafc;
 }
 }
@@ -414,6 +412,13 @@ a:focus-visible {
   background: #eff6ff;
   background: #eff6ff;
 }
 }
 
 
+.demand-cell,
+.group-demand {
+  width: 300px;
+  min-width: 300px;
+  max-width: 300px;
+}
+
 .source-cell,
 .source-cell,
 .sub-source,
 .sub-source,
 .group-source {
 .group-source {
@@ -441,22 +446,104 @@ a:focus-visible {
   min-width: 130px;
   min-width: 130px;
 }
 }
 
 
-.sticky-source {
+.sticky-demand {
   position: sticky !important;
   position: sticky !important;
   left: 0;
   left: 0;
+  z-index: 4;
+}
+
+.sticky-source {
+  position: sticky !important;
+  left: 300px;
   z-index: 3;
   z-index: 3;
 }
 }
 
 
+.ledger-table thead .sticky-demand {
+  z-index: 7;
+}
+
 .ledger-table thead .sticky-source {
 .ledger-table thead .sticky-source {
   z-index: 6;
   z-index: 6;
 }
 }
 
 
+.demand-cell,
+.group-demand,
 .source-cell,
 .source-cell,
 .sub-source,
 .sub-source,
 .group-source {
 .group-source {
   box-shadow: 6px 0 8px -4px rgba(0, 0, 0, 0.18);
   box-shadow: 6px 0 8px -4px rgba(0, 0, 0, 0.18);
 }
 }
 
 
+.demand-card {
+  display: grid;
+  gap: 7px;
+}
+
+.demand-card-head,
+.demand-meta-row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 5px;
+  align-items: center;
+}
+
+.demand-title {
+  color: #111827;
+  font-size: 13px;
+  line-height: 1.45;
+}
+
+.demand-line {
+  margin: 0;
+  color: #334155;
+  font-size: 11.5px;
+  line-height: 1.55;
+}
+
+.demand-line b,
+.demand-list b {
+  color: #111827;
+  font-weight: 850;
+}
+
+.demand-meta-row span {
+  padding: 2px 6px;
+  border-radius: 4px;
+  color: #475569;
+  background: #e2e8f0;
+  font-size: 10.5px;
+  font-weight: 800;
+}
+
+.demand-list {
+  display: grid;
+  gap: 4px;
+  color: #334155;
+  font-size: 11.5px;
+  line-height: 1.55;
+}
+
+.demand-list div {
+  display: grid;
+  gap: 3px;
+}
+
+.demand-list span {
+  overflow-wrap: anywhere;
+}
+
+.demand-list em {
+  color: var(--muted);
+  font-style: normal;
+}
+
+.repeated-demand {
+  color: #64748b;
+  font-size: 11.5px;
+  font-weight: 800;
+  text-align: center;
+}
+
 .idx {
 .idx {
   display: inline-block;
   display: inline-block;
   margin-bottom: 5px;
   margin-bottom: 5px;
@@ -2254,6 +2341,8 @@ a.walk-flow-node:hover {
     margin-left: 0;
     margin-left: 0;
   }
   }
 
 
+  .demand-cell,
+  .group-demand,
   .source-cell,
   .source-cell,
   .sub-source,
   .sub-source,
   .group-source {
   .group-source {
@@ -2262,11 +2351,14 @@ a.walk-flow-node:hover {
     max-width: 260px;
     max-width: 260px;
   }
   }
 
 
+  .sticky-demand,
   .sticky-source {
   .sticky-source {
     position: static !important;
     position: static !important;
     left: auto;
     left: auto;
   }
   }
 
 
+  .demand-cell,
+  .group-demand,
   .source-cell,
   .source-cell,
   .sub-source,
   .sub-source,
   .group-source {
   .group-source {

+ 68 - 1
web2/features/LedgerPage.tsx

@@ -14,7 +14,7 @@ import {
   sourceLabel,
   sourceLabel,
   walkSummaryText
   walkSummaryText
 } from "@/lib/flow-ledger/business";
 } from "@/lib/flow-ledger/business";
-import type { FlowLedgerRow, VideoRef } from "@/lib/flow-ledger/types";
+import type { DemandSummary, FlowLedgerRow, VideoRef } from "@/lib/flow-ledger/types";
 import { useFlowLedger } from "./useFlowLedger";
 import { useFlowLedger } from "./useFlowLedger";
 
 
 export function LedgerPage({ runId }: { runId: string }) {
 export function LedgerPage({ runId }: { runId: string }) {
@@ -37,6 +37,7 @@ export function LedgerPage({ runId }: { runId: string }) {
             <table className="ledger-table">
             <table className="ledger-table">
               <thead>
               <thead>
                 <tr>
                 <tr>
+                  <th className="group-demand sticky-demand">原始需求</th>
                   <th className="group-source sticky-source">需求 -&gt; query</th>
                   <th className="group-source sticky-source">需求 -&gt; query</th>
                   <th className="group-videos">query -&gt; 渠道</th>
                   <th className="group-videos">query -&gt; 渠道</th>
                   <th className="group-rules">渠道 -&gt; 判断</th>
                   <th className="group-rules">渠道 -&gt; 判断</th>
@@ -47,6 +48,7 @@ export function LedgerPage({ runId }: { runId: string }) {
               <tbody>
               <tbody>
                 {rows.map((row) => (
                 {rows.map((row) => (
                   <tr className="ledger-row" key={row.id}>
                   <tr className="ledger-row" key={row.id}>
+                    <DemandCell demand={ledger.demandSummary} isFirst={row.id === rows[0]?.id} />
                     <SourceCell row={row} />
                     <SourceCell row={row} />
                     <VideoCell runId={runId} row={row} />
                     <VideoCell runId={runId} row={row} />
                     <RuleSummaryCell row={row} />
                     <RuleSummaryCell row={row} />
@@ -63,6 +65,71 @@ export function LedgerPage({ runId }: { runId: string }) {
   );
   );
 }
 }
 
 
+function DemandCell({ demand, isFirst }: { demand?: DemandSummary; isFirst: boolean }) {
+  if (!isFirst) {
+    return (
+      <td className="demand-cell sticky-demand repeated-demand">
+        <span>同需求单 {demand?.id || "待确认"}</span>
+      </td>
+    );
+  }
+  if (!demand) {
+    return (
+      <td className="demand-cell sticky-demand">
+        <div className="cell-stack">
+          <strong>需求待确认</strong>
+          <span className="muted">当前运行没有写入原始需求单摘要</span>
+        </div>
+      </td>
+    );
+  }
+  return (
+    <td className="demand-cell sticky-demand">
+      <div className="demand-card">
+        <div className="demand-card-head">
+          <span className="chip dark">需求单 {demand.id || "待确认"}</span>
+          {demand.source ? <span className="chip blue">{demand.source}</span> : null}
+        </div>
+        <strong className="demand-title">{demand.name || "未命名需求"}</strong>
+        <DemandLine label="需求描述" value={demand.description} />
+        <DemandLine label="形成原因" value={demand.reason} />
+        <div className="demand-meta-row">
+          {demand.itemsetIds.length ? <span>itemset {demand.itemsetIds.join(" / ")}</span> : null}
+          {demand.support ? <span>支持 {demand.support}</span> : null}
+          {demand.sourcePostId ? <span>来源帖子 {demand.sourcePostId}</span> : null}
+        </div>
+        {demand.seedTerms.length ? <DemandLine label="Pattern 词" value={demand.seedTerms.join("、")} /> : null}
+        {demand.categoryPaths.length ? <DemandList label="分类树路径" items={demand.categoryPaths} limit={3} /> : null}
+        {demand.extractedPoints.length ? <DemandList label="帖子抽取点" items={demand.extractedPoints} limit={4} /> : null}
+      </div>
+    </td>
+  );
+}
+
+function DemandLine({ label, value }: { label: string; value?: string }) {
+  if (!value) return null;
+  return (
+    <p className="demand-line">
+      <b>{label}:</b>
+      <span>{value}</span>
+    </p>
+  );
+}
+
+function DemandList({ label, items, limit }: { label: string; items: string[]; limit: number }) {
+  const visible = items.slice(0, limit);
+  const more = Math.max(0, items.length - visible.length);
+  return (
+    <div className="demand-list">
+      <b>{label}:</b>
+      <div>
+        {visible.map((item) => <span key={item}>{item}</span>)}
+        {more ? <em>还有 {more} 项</em> : null}
+      </div>
+    </div>
+  );
+}
+
 function HeaderSummaryChips({ rows, dataOriginLabel }: { rows: FlowLedgerRow[]; dataOriginLabel: string }) {
 function HeaderSummaryChips({ rows, dataOriginLabel }: { rows: FlowLedgerRow[]; dataOriginLabel: string }) {
   return (
   return (
     <div className="header-summary-chips">
     <div className="header-summary-chips">

+ 1 - 0
web2/lib/api/types.ts

@@ -100,6 +100,7 @@ export type FlowLedgerApiResponse = {
   data_origin: DataOrigin;
   data_origin: DataOrigin;
   data_origin_label: string;
   data_origin_label: string;
   summary: RawRecord;
   summary: RawRecord;
+  demand_summary?: RawRecord | null;
   rows: RawRecord[];
   rows: RawRecord[];
   extension_queries: RawRecord[];
   extension_queries: RawRecord[];
   debug?: RawRecord | null;
   debug?: RawRecord | null;

+ 24 - 1
web2/lib/flow-ledger/build.ts

@@ -1,5 +1,5 @@
 import type { FlowLedgerApiResponse, RawRecord } from "@/lib/api/types";
 import type { FlowLedgerApiResponse, RawRecord } from "@/lib/api/types";
-import type { FlowLedger, FlowLedgerRow, ScoreItem, VideoRef } from "./types";
+import type { DemandSummary, FlowLedger, FlowLedgerRow, ScoreItem, VideoRef } from "./types";
 import { asRecord, maybeText, scoreLabel, text, textList } from "./format";
 import { asRecord, maybeText, scoreLabel, text, textList } from "./format";
 
 
 function countMap(value: unknown): Record<string, number> {
 function countMap(value: unknown): Record<string, number> {
@@ -113,6 +113,28 @@ function rowFromApi(row: RawRecord): FlowLedgerRow {
   };
   };
 }
 }
 
 
+function demandSummaryFromApi(row: RawRecord | null | undefined): DemandSummary | undefined {
+  if (!row) return undefined;
+  return {
+    id: text(row.id, ""),
+    name: text(row.name, ""),
+    description: text(row.description, ""),
+    reason: text(row.reason, ""),
+    source: text(row.source, ""),
+    score: row.score === null || row.score === undefined || row.score === "" ? undefined : text(row.score, ""),
+    date: text(row.date, ""),
+    type: text(row.type, ""),
+    itemsetIds: textList(row.itemset_ids),
+    seedTerms: textList(row.seed_terms),
+    sourcePostId: text(row.source_post_id, ""),
+    support: row.support === null || row.support === undefined || row.support === "" ? undefined : text(row.support, ""),
+    patternExecutionId: text(row.pattern_execution_id, ""),
+    miningConfigId: text(row.mining_config_id, ""),
+    categoryPaths: textList(row.category_paths),
+    extractedPoints: textList(row.extracted_points)
+  };
+}
+
 export function buildFlowLedger(input: FlowLedgerApiResponse): FlowLedger {
 export function buildFlowLedger(input: FlowLedgerApiResponse): FlowLedger {
   const rows = (input.rows || []).map(rowFromApi);
   const rows = (input.rows || []).map(rowFromApi);
   return {
   return {
@@ -120,6 +142,7 @@ export function buildFlowLedger(input: FlowLedgerApiResponse): FlowLedger {
     dataOrigin: input.data_origin,
     dataOrigin: input.data_origin,
     dataOriginLabel: input.data_origin_label,
     dataOriginLabel: input.data_origin_label,
     summary: input.summary || {},
     summary: input.summary || {},
+    demandSummary: demandSummaryFromApi(input.demand_summary),
     extensionQueries: input.extension_queries || [],
     extensionQueries: input.extension_queries || [],
     rows,
     rows,
     raw: {
     raw: {

+ 20 - 0
web2/lib/flow-ledger/types.ts

@@ -87,6 +87,25 @@ export type AssetSummary = {
   headline?: string;
   headline?: string;
 };
 };
 
 
+export type DemandSummary = {
+  id: string;
+  name: string;
+  description: string;
+  reason: string;
+  source: string;
+  score?: string;
+  date: string;
+  type: string;
+  itemsetIds: string[];
+  seedTerms: string[];
+  sourcePostId: string;
+  support?: string;
+  patternExecutionId: string;
+  miningConfigId: string;
+  categoryPaths: string[];
+  extractedPoints: string[];
+};
+
 export type FlowLedgerRow = {
 export type FlowLedgerRow = {
   id: string;
   id: string;
   source: SourceSummary;
   source: SourceSummary;
@@ -105,6 +124,7 @@ export type FlowLedger = {
   dataOrigin: string;
   dataOrigin: string;
   dataOriginLabel: string;
   dataOriginLabel: string;
   summary: RawRecord;
   summary: RawRecord;
+  demandSummary?: DemandSummary;
   extensionQueries: RawRecord[];
   extensionQueries: RawRecord[];
   rows: FlowLedgerRow[];
   rows: FlowLedgerRow[];
   raw: {
   raw: {