Jelajahi Sumber

feat(frontend): link query preview to latest singleton run

SamLee 3 minggu lalu
induk
melakukan
0b59bd9b0f

+ 87 - 0
acquisition/repositories/postgres.py

@@ -534,6 +534,93 @@ class PostgresAcquisitionRepository:
             "classifications": classifications,
         }
 
+    def get_latest_singleton_overview(self) -> dict[str, Any]:
+        batch = self._one_or_none(
+            """
+            SELECT * FROM query_batches
+            WHERE generation_method = %s
+            ORDER BY created_at DESC
+            LIMIT 1
+            """,
+            ("creation_singleton_v1",),
+        )
+        if batch is None:
+            return {"batch": None, "run": None, "queries": [], "decoded_items": []}
+
+        run = self._one_or_none(
+            """
+            SELECT * FROM acquisition_runs
+            WHERE batch_id = %s AND run_key LIKE %s
+            ORDER BY created_at DESC
+            LIMIT 1
+            """,
+            (batch["id"], "singleton-acquisition:%"),
+        )
+        if run is None:
+            queries = self._all(
+                """
+                SELECT
+                    q.id AS query_id,
+                    q.query_text,
+                    q.metadata->>'family_key' AS family_key,
+                    q.sort_order,
+                    0::int AS candidate_count,
+                    0::int AS creation_hit_count
+                FROM queries q
+                WHERE q.batch_id = %s
+                ORDER BY q.sort_order, q.created_at
+                """,
+                (batch["id"],),
+            )
+            return {"batch": batch, "run": None, "queries": queries, "decoded_items": []}
+
+        queries = self._all(
+            """
+            SELECT
+                q.id AS query_id,
+                q.query_text,
+                q.metadata->>'family_key' AS family_key,
+                q.sort_order,
+                COUNT(DISTINCT ci.id)::int AS candidate_count,
+                COUNT(DISTINCT ic.id) FILTER (
+                    WHERE ic.is_creation_knowledge IS TRUE
+                )::int AS creation_hit_count
+            FROM queries q
+            LEFT JOIN acquisition_jobs aj ON aj.query_id = q.id AND aj.run_id = %s
+            LEFT JOIN candidate_items ci ON ci.job_id = aj.id
+            LEFT JOIN item_classifications ic ON ic.item_id = ci.id
+            WHERE q.batch_id = %s
+            GROUP BY q.id, q.query_text, q.metadata, q.sort_order
+            ORDER BY q.sort_order, q.created_at
+            """,
+            (run["id"], batch["id"]),
+        )
+        decoded_items = self._all(
+            """
+            SELECT
+                ci.id AS item_id,
+                ci.query_id,
+                ci.title,
+                ci.platform,
+                dr.status AS decode_status,
+                COUNT(DISTINCT pd.id)::int AS payload_count
+            FROM candidate_items ci
+            JOIN acquisition_jobs aj ON aj.id = ci.job_id
+            JOIN decode_results dr ON dr.item_id = ci.id
+            LEFT JOIN payload_drafts pd ON pd.item_id = ci.id
+            WHERE aj.run_id = %s
+            GROUP BY ci.id, ci.query_id, ci.title, ci.platform, dr.status, dr.created_at
+            ORDER BY dr.created_at DESC
+            """,
+            (run["id"],),
+        )
+        return {
+            "batch": batch,
+            "run": run,
+            "queries": queries,
+            "decoded_items": decoded_items,
+        }
+
     def list_creation_candidate_items(
         self,
         *,

File diff ditekan karena terlalu besar
+ 0 - 0
app/frontend/dist/assets/index-B-5DKb0w.css


File diff ditekan karena terlalu besar
+ 0 - 0
app/frontend/dist/assets/index-B8vY7s1D.js


File diff ditekan karena terlalu besar
+ 0 - 0
app/frontend/dist/assets/index-BmczRVfS.css


+ 2 - 2
app/frontend/dist/index.html

@@ -4,8 +4,8 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>创作知识 · Query 正交 Demo</title>
-    <script type="module" crossorigin src="/app/assets/index-D22dQVWx.js"></script>
-    <link rel="stylesheet" crossorigin href="/app/assets/index-B-5DKb0w.css">
+    <script type="module" crossorigin src="/app/assets/index-B8vY7s1D.js"></script>
+    <link rel="stylesheet" crossorigin href="/app/assets/index-BmczRVfS.css">
   </head>
   <body>
     <div id="root"></div>

+ 83 - 12
app/frontend/src/pages/CreationDemo.jsx

@@ -69,17 +69,88 @@ function AxisColumn({ data, family, axis }) {
   )
 }
 
+function singletonMaps(singleton) {
+  const byQueryText = new Map()
+  const decodedByQueryId = new Map()
+  for (const item of singleton?.decoded_items || []) {
+    const key = item.query_id || ''
+    decodedByQueryId.set(key, [...(decodedByQueryId.get(key) || []), item])
+  }
+  for (const row of singleton?.queries || []) {
+    byQueryText.set(row.query_text, {
+      ...row,
+      decoded_items: decodedByQueryId.get(row.query_id) || [],
+    })
+  }
+  return byQueryText
+}
+
+function SingletonLinks({ singleton }) {
+  if (!singleton?.batch || !singleton?.run) return null
+  const firstDecoded = singleton.decoded_items?.[0]
+  const firstQuery = singleton.queries?.find((row) => row.query_id === firstDecoded?.query_id) || singleton.queries?.[0]
+  const searchHref = firstQuery ? `#/query/${encodeURIComponent(singleton.run.id)}/${encodeURIComponent(firstQuery.query_id)}` : ''
+  const decodeHref = firstDecoded ? `#/decode-item/${encodeURIComponent(firstDecoded.item_id)}` : ''
+  const payloadText = firstDecoded?.payload_count ? ` ${firstDecoded.payload_count} payload` : ''
+  return (
+    <div className="singleton-panel">
+      <div>
+        <b>最近真实单例</b>
+        <span>{singleton.batch.name} · {singleton.run.status}</span>
+      </div>
+      <div className="singleton-actions">
+        {searchHref && <a className="btn ghost" href={searchHref}>查看搜索结果</a>}
+        {decodeHref && <a className="btn" href={decodeHref}>查看拆解结果{payloadText}</a>}
+      </div>
+    </div>
+  )
+}
+
+function PreviewQueryRow({ item, index, latest, singleton }) {
+  const decoded = latest?.decoded_items?.[0]
+  const searchHref = latest && singleton?.run
+    ? `#/query/${encodeURIComponent(singleton.run.id)}/${encodeURIComponent(latest.query_id)}`
+    : ''
+  const decodeHref = decoded ? `#/decode-item/${encodeURIComponent(decoded.item_id)}` : ''
+  return (
+    <div className={`qrow ${item.keep === false ? 'drop' : 'keep'}`} key={`${item.query}-${index}`} title={item.reason || ''}>
+      <span className="qmark">{item.keep === false ? '✕' : '✓'}</span>
+      {typeof item.valid === 'number' && (
+        <span className={`qvalid ${item.valid >= 6 ? 'ok' : 'low'}`}>语义{item.valid}</span>
+      )}
+      <span className="qtext">{item.query}</span>
+      {latest ? (
+        <>
+          <span className="qcreation done">{latest.creation_hit_count}/{latest.candidate_count} 命中</span>
+          <a className="qdetail on" href={searchHref}>查看搜索结果</a>
+        </>
+      ) : (
+        <span className="qdetail">未搜索</span>
+      )}
+      {decodeHref && <a className="qdetail decode-on" href={decodeHref}>查看拆解结果<span>{decoded.payload_count} payload</span></a>}
+      {item.reason && <span className="qreason">{item.reason}</span>}
+    </div>
+  )
+}
+
 function QueryGenerationPreview() {
   const [data, setData] = useState(null)
+  const [singleton, setSingleton] = useState(null)
   const [activeKey, setActiveKey] = useState('f1')
   const [err, setErr] = useState('')
 
   useEffect(() => {
     setErr('')
-    fetch('/api/query-generation/preview?per=30&dry=true')
-      .then((r) => (r.ok ? r.json() : Promise.reject(new Error('query 生成预览读取失败'))))
-      .then((payload) => {
+    Promise.all([
+      fetch('/api/query-generation/preview?per=30&dry=true')
+        .then((r) => (r.ok ? r.json() : Promise.reject(new Error('query 生成预览读取失败')))),
+      fetch('/api/query-generation/latest-singleton')
+        .then((r) => (r.ok ? r.json() : null))
+        .catch(() => null),
+    ])
+      .then(([payload, latest]) => {
         setData(payload)
+        setSingleton(latest)
         const firstKey = payload.families?.[0]?.key
         if (firstKey) setActiveKey(firstKey)
       })
@@ -92,6 +163,7 @@ function QueryGenerationPreview() {
   const families = data.families || []
   const family = families.find((row) => row.key === activeKey) || families[0]
   const kept = (family?.items || []).filter((item) => item.keep !== false).length
+  const latestByQuery = singletonMaps(singleton)
 
   return (
     <div>
@@ -99,6 +171,7 @@ function QueryGenerationPreview() {
       <div className="sub">
         只显示当前激活的 2 种正交方式:f1 / f2。生成逻辑来自正式 query builder;此页只预览,不写 DB、不发起搜索。
       </div>
+      <SingletonLinks singleton={singleton} />
 
       <div className="famcols two">
         {families.map((row) => (
@@ -126,15 +199,13 @@ function QueryGenerationPreview() {
           </div>
           <div className="axlist">
             {(family.items || []).map((item, index) => (
-              <div className={`qrow ${item.keep === false ? 'drop' : 'keep'}`} key={`${item.query}-${index}`} title={item.reason || ''}>
-                <span className="qmark">{item.keep === false ? '✕' : '✓'}</span>
-                {typeof item.valid === 'number' && (
-                  <span className={`qvalid ${item.valid >= 6 ? 'ok' : 'low'}`}>语义{item.valid}</span>
-                )}
-                <span className="qtext">{item.query}</span>
-                <span className="qdetail">未搜索</span>
-                {item.reason && <span className="qreason">{item.reason}</span>}
-              </div>
+              <PreviewQueryRow
+                item={item}
+                index={index}
+                key={`${item.query}-${index}`}
+                latest={latestByQuery.get(item.query)}
+                singleton={singleton}
+              />
             ))}
           </div>
         </div>

+ 6 - 0
app/frontend/src/styles.css

@@ -25,6 +25,11 @@ h1 { font-size: 22px; margin: 0 0 2px; letter-spacing: .5px; }
 
 /* 创作Demo:家族选择器左右两栏竖排,按尾缀分组 */
 .famcols { display: flex; gap: 16px; align-items: flex-start; margin-bottom: 16px; }
+.singleton-panel { display: flex; align-items: center; justify-content: space-between; gap: 12px;
+  background: #fff; border: 1px solid var(--line); border-radius: 10px; padding: 11px 13px; margin: 0 0 16px; }
+.singleton-panel b { display: block; font-size: 13.5px; }
+.singleton-panel span { color: var(--muted); font-size: 12px; }
+.singleton-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
 .famcol { flex: 1 1 0; display: flex; flex-direction: column; gap: 6px; }
 .famcol-hd { font-size: 12px; font-weight: 700; color: var(--muted); padding: 0 2px 4px; }
 .fbtn { display: flex; justify-content: space-between; align-items: baseline; gap: 14px; text-align: left; padding: 9px 12px; border: 1px solid var(--line); border-radius: 8px; background: var(--card); font-size: 13px; color: var(--ink); cursor: pointer; }
@@ -75,6 +80,7 @@ h1 { font-size: 22px; margin: 0 0 2px; letter-spacing: .5px; }
 .qdetail { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--line);
   border-radius: 8px; padding: 2px 7px; font-size: 11.5px; color: var(--muted); background: #fff; }
 .qdetail.on { color: var(--accent); border-color: var(--accent-soft); background: var(--accent-soft); }
+.qdetail.decode-on { color: var(--ok); border-color: #d8eadf; background: #e6f6ee; font-weight: 700; }
 .qdetail span { color: inherit; opacity: .8; }
 .qcreation { flex: 0 0 auto; font-size: 11.5px; font-weight: 700; border-radius: 9px; padding: 1px 7px; white-space: nowrap; }
 .qcreation.done { color: var(--ok); background: #e6f6ee; }

+ 12 - 2
app/routes/query_generation.py

@@ -3,10 +3,10 @@ from __future__ import annotations
 
 from typing import Any
 
-from fastapi import APIRouter, Query
+from fastapi import APIRouter, Depends, Query
 
 from acquisition.queries.builder import QueryBuildOptions, TREES, build_creation_query_batch
-from app.dependencies import _env_file
+from app.dependencies import _env_file, get_acquisition_repository
 from core.config import Settings
 
 router = APIRouter(prefix="/api/query-generation", tags=["query-generation"])
@@ -46,3 +46,13 @@ def query_generation_preview(
     )
     generated["summary"] = _summary(generated)
     return generated
+
+
+@router.get("/latest-singleton")
+def latest_singleton_overview(repo: Any = Depends(get_acquisition_repository)) -> dict[str, Any]:
+    """Return links from the query preview board to the latest real singleton run."""
+
+    getter = getattr(repo, "get_latest_singleton_overview", None)
+    if getter is None:
+        return {"batch": None, "run": None, "queries": [], "decoded_items": []}
+    return getter()

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini