Skip to content

Commit ae0adbf

Browse files
committed
Log search results to S3 and expose in admin
1 parent 1226649 commit ae0adbf

File tree

3 files changed

+204
-2
lines changed

3 files changed

+204
-2
lines changed

app/api/routes_admin.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ def _render_template(name: str, **replacements: str) -> str:
7676

7777
_LOG_TYPES = {
7878
"ai_agent": "openai_ai_agent_usage/",
79+
"search": "search_usage/",
7980
"variants": "variants_openai_usage/",
8081
"debug": "simulation_debug/",
8182
}
@@ -166,6 +167,27 @@ def _parse_variants_log(payload: Dict[str, Any]) -> Dict[str, Any]:
166167
}
167168

168169

170+
def _parse_search_log(payload: Dict[str, Any]) -> Dict[str, Any]:
171+
query = payload.get("query") or {}
172+
result_counts = payload.get("result_counts") or {}
173+
total_results = (
174+
(result_counts.get("image_top") or 0)
175+
+ (result_counts.get("image_misc") or 0)
176+
+ (result_counts.get("text_top") or 0)
177+
+ (result_counts.get("text_misc") or 0)
178+
)
179+
return {
180+
"timestamp": payload.get("timestamp") or "",
181+
"search_id": payload.get("search_id") or "",
182+
"query_text": query.get("text") or "",
183+
"goods_classes": ", ".join(query.get("goods_classes") or []),
184+
"group_codes": ", ".join(query.get("group_codes") or []),
185+
"total_results": total_results,
186+
"client_ip": payload.get("client_ip") or "",
187+
"user_agent": payload.get("user_agent") or "",
188+
}
189+
190+
169191
def _parse_debug_key(key: str) -> Dict[str, Any]:
170192
filename = key.split("/")[-1]
171193
run_tag = ""
@@ -296,6 +318,17 @@ def admin_logs(request: Request, type: str) -> JSONResponse:
296318
**parsed,
297319
}
298320
)
321+
elif type == "search":
322+
parsed = _parse_search_log(payload)
323+
items.append(
324+
{
325+
"key": obj["key"],
326+
"last_modified": obj["last_modified"].isoformat()
327+
if obj.get("last_modified")
328+
else "",
329+
**parsed,
330+
}
331+
)
299332

300333
note = ""
301334
if truncated:
@@ -304,7 +337,7 @@ def admin_logs(request: Request, type: str) -> JSONResponse:
304337
return JSONResponse(
305338
{
306339
"items": items,
307-
"total_cost_usd": total_cost,
340+
"total_cost_usd": total_cost if type in {"ai_agent", "variants"} else 0,
308341
"note": note,
309342
}
310343
)

app/services/worker_bridge.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@
33
from __future__ import annotations
44

55
import base64
6+
import json
67
import logging
78
import time
89
import uuid
10+
import threading
11+
from dataclasses import asdict
12+
from datetime import datetime
913
from typing import Any, Dict, List, Optional
1014

1115
from app.schemas.search import (
@@ -19,6 +23,7 @@
1923
)
2024
from app.services.s3_storage import ImageRef, ImageTransferError, build_image_ref
2125
from app.services.request_meta import get_request_meta
26+
from app.services.log_storage import upload_text, s3_logs_enabled
2227
from app.services.worker_registry import (
2328
WorkerDisconnectedError,
2429
WorkerTimeoutError,
@@ -32,6 +37,79 @@
3237
logger = logging.getLogger(__name__)
3338

3439

40+
def _upload_text_async(key_suffix: str, text: str, content_type: str) -> None:
41+
thread = threading.Thread(
42+
target=upload_text,
43+
args=(key_suffix, text, content_type),
44+
daemon=True,
45+
)
46+
thread.start()
47+
48+
49+
def _serialize_result(result: SearchResult) -> Dict[str, Any]:
50+
payload = asdict(result)
51+
return {
52+
"trademark_id": payload.get("trademark_id") or "",
53+
"title": payload.get("title") or "",
54+
"status": payload.get("status") or "",
55+
"class_codes": payload.get("class_codes") or [],
56+
"app_no": payload.get("app_no") or "",
57+
"image_sim": float(payload.get("image_sim") or 0.0),
58+
"text_sim": float(payload.get("text_sim") or 0.0),
59+
"thumb_url": payload.get("thumb_url"),
60+
"doi": payload.get("doi"),
61+
"image_path": payload.get("image_path"),
62+
"goods_services": payload.get("goods_services"),
63+
}
64+
65+
66+
def _build_search_log_payload(
67+
req: SearchRequest,
68+
response: SearchResponse,
69+
*,
70+
job_id: str,
71+
worker_id: str,
72+
elapsed_ms: int,
73+
) -> Dict[str, Any]:
74+
meta = get_request_meta()
75+
query = response.query
76+
payload = {
77+
"timestamp": datetime.utcnow().isoformat(),
78+
"search_id": response.search_id or "",
79+
"job_id": job_id,
80+
"worker_id": worker_id,
81+
"elapsed_ms": elapsed_ms,
82+
"query": {
83+
"text": query.text,
84+
"language": req.language,
85+
"goods_classes": list(query.goods_classes or []),
86+
"group_codes": list(query.group_codes or []),
87+
"variants": list(query.variants or []),
88+
"k": query.k,
89+
"use_llm_variants": bool(req.use_llm_variants),
90+
"debug": bool(req.debug),
91+
},
92+
"result_counts": {
93+
"image_top": len(response.image_top or []),
94+
"image_misc": len(response.image_misc or []),
95+
"text_top": len(response.text_top or []),
96+
"text_misc": len(response.text_misc or []),
97+
},
98+
"image_top": [_serialize_result(item) for item in response.image_top or []],
99+
"image_misc": [_serialize_result(item) for item in response.image_misc or []],
100+
"text_top": [_serialize_result(item) for item in response.text_top or []],
101+
"text_misc": [_serialize_result(item) for item in response.text_misc or []],
102+
"client_id": meta.client_id if meta else "",
103+
"client_ip": meta.client_ip if meta else "",
104+
"user_agent": meta.user_agent if meta else "",
105+
"request_id": meta.request_id if meta else "",
106+
"origin": meta.origin if meta else "",
107+
"referer": meta.referer if meta else "",
108+
"accept_language": meta.accept_language if meta else "",
109+
}
110+
return payload
111+
112+
35113
class WorkerSearchError(Exception):
36114
"""Base search error when using desktop worker."""
37115

@@ -298,4 +376,21 @@ async def run_worker_search(req: SearchRequest) -> SearchResponse:
298376
worker_id,
299377
elapsed_ms,
300378
)
379+
if s3_logs_enabled():
380+
try:
381+
payload = _build_search_log_payload(
382+
req,
383+
response,
384+
job_id=job_id,
385+
worker_id=worker_id,
386+
elapsed_ms=elapsed_ms,
387+
)
388+
date_tag = datetime.utcnow().strftime("%Y/%m/%d")
389+
_upload_text_async(
390+
f"search_usage/{date_tag}/{uuid.uuid4().hex}.json",
391+
json.dumps(payload, ensure_ascii=False),
392+
content_type="application/json",
393+
)
394+
except Exception:
395+
logger.exception("Failed to enqueue search log upload")
301396
return response

app/templates/admin_home.html

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
body { font-family: system-ui, sans-serif; background:#f6f8fc; margin:0; padding:2rem; overflow-x:hidden; }
1010
header { display:flex; justify-content:space-between; align-items:center; margin-bottom:1.5rem; }
1111
h1 { margin:0; }
12-
.section { background:#fff; padding:1.5rem; border-radius:16px; box-shadow:0 8px 24px rgba(15,23,42,0.08); margin-bottom:1.5rem; overflow-x:auto; }
12+
.section { background:#fff; padding:1.5rem; border-radius:16px; box-shadow:0 8px 24px rgba(15,23,42,0.08); margin-bottom:1.5rem; overflow-x:auto; -webkit-overflow-scrolling:touch; overscroll-behavior-x:contain; }
1313
.section h2 { margin:0; font-size:1.1rem; }
1414
.section-header { display:flex; justify-content:space-between; align-items:flex-end; gap:1rem; margin-bottom:0.75rem; }
1515
.summary { color:#475569; font-size:0.9rem; }
@@ -31,6 +31,10 @@
3131
.page-btn { border:1px solid #e2e8f0; background:#fff; color:#0f172a; padding:0.25rem 0.55rem; border-radius:8px; cursor:pointer; font-size:0.85rem; }
3232
.page-btn.is-active { background:#1d4ed8; color:#fff; border-color:#1d4ed8; }
3333
.page-btn:disabled { cursor:default; opacity:0.4; }
34+
@media (max-width: 1024px) {
35+
body { padding:1rem; }
36+
.section { padding:1rem; }
37+
}
3438
@media (max-width: 768px) {
3539
body { padding:0.5rem; }
3640
.section { padding:0.6rem; border-radius:12px; }
@@ -73,6 +77,32 @@ <h2>시뮬레이션 AI Agent 로그</h2>
7377
<div class="summary muted" id="ai-note"></div>
7478
</section>
7579

80+
<section class="section">
81+
<div class="section-header">
82+
<h2>검색 로그</h2>
83+
<div class="summary" id="search-summary">총 {count}건</div>
84+
</div>
85+
<table>
86+
<thead>
87+
<tr>
88+
<th class="sortable narrow" data-table="search" data-key="timestamp">시간<span class="sort-indicator"></span></th>
89+
<th class="narrow">search_id</th>
90+
<th>검색어</th>
91+
<th>상품류</th>
92+
<th>유사군</th>
93+
<th class="narrow">결과 수</th>
94+
<th>IP</th>
95+
<th class="col-user-agent">user_agent</th>
96+
</tr>
97+
</thead>
98+
<tbody id="search-table">
99+
<tr><td colspan="8" class="muted">로딩 중...</td></tr>
100+
</tbody>
101+
</table>
102+
<div class="pagination" id="search-pagination"></div>
103+
<div class="summary muted" id="search-note"></div>
104+
</section>
105+
76106
<section class="section">
77107
<div class="section-header">
78108
<h2>LLM 유사어 로그</h2>
@@ -148,6 +178,7 @@ <h2>시뮬레이션 내용 로그</h2>
148178

149179
const state = {
150180
ai: { items: [], total_cost_usd: 0, sortKey: "last_modified", sortDir: "desc", page: 1 },
181+
search: { items: [], sortKey: "timestamp", sortDir: "desc", page: 1 },
151182
variants: { items: [], total_cost_usd: 0, sortKey: "timestamp", sortDir: "desc", page: 1 },
152183
debug: { items: [], sortKey: "last_modified", sortDir: "desc", page: 1 },
153184
};
@@ -209,6 +240,7 @@ <h2>시뮬레이션 내용 로그</h2>
209240
if (!Number.isFinite(next) || next < 1 || next > totalPages) return;
210241
st.page = next;
211242
if (tableKey === "ai") renderAiAgent();
243+
if (tableKey === "search") renderSearch();
212244
if (tableKey === "variants") renderVariants();
213245
if (tableKey === "debug") renderDebug();
214246
});
@@ -258,6 +290,38 @@ <h2>시뮬레이션 내용 로그</h2>
258290
updateSortIndicators("ai");
259291
};
260292

293+
const renderSearch = () => {
294+
const body = document.getElementById("search-table");
295+
body.innerHTML = "";
296+
const sorted = sortItems(state.search.items, state.search.sortKey, state.search.sortDir);
297+
const items = paginateItems(sorted, state.search.page);
298+
if (!items.length) {
299+
body.innerHTML = '<tr><td colspan="8" class="muted">데이터가 없습니다.</td></tr>';
300+
return;
301+
}
302+
items.forEach((item) => {
303+
const row = document.createElement("tr");
304+
row.classList.add("clickable");
305+
row.innerHTML = `
306+
<td class="narrow">${formatDate(item.timestamp)}</td>
307+
<td class="narrow">${item.search_id || "-"}</td>
308+
<td>${item.query_text || "-"}</td>
309+
<td>${item.goods_classes || "-"}</td>
310+
<td>${item.group_codes || "-"}</td>
311+
<td class="narrow">${item.total_results ?? "-"}</td>
312+
<td>${item.client_ip || "-"}</td>
313+
<td class="col-user-agent">${item.user_agent || "-"}</td>
314+
`;
315+
row.addEventListener("click", () => toggleDetail(row, item.key, 8));
316+
body.appendChild(row);
317+
});
318+
document.getElementById("search-summary").textContent =
319+
`총 ${state.search.items.length}건`;
320+
document.getElementById("search-note").textContent = state.search.note || "";
321+
renderPagination("search-pagination", "search");
322+
updateSortIndicators("search");
323+
};
324+
261325
const renderVariants = () => {
262326
const body = document.getElementById("variants-table");
263327
body.innerHTML = "";
@@ -350,6 +414,7 @@ <h2>시뮬레이션 내용 로그</h2>
350414
}
351415
st.page = 1;
352416
if (table === "ai") renderAiAgent();
417+
if (table === "search") renderSearch();
353418
if (table === "variants") renderVariants();
354419
if (table === "debug") renderDebug();
355420
});
@@ -367,6 +432,15 @@ <h2>시뮬레이션 내용 로그</h2>
367432
document.getElementById("ai-table").innerHTML =
368433
`<tr><td colspan="7" class="muted">불러오기 실패</td></tr>`;
369434
}
435+
try {
436+
const data = await fetchLogs("search");
437+
state.search.items = data.items || [];
438+
state.search.note = data.note || "";
439+
renderSearch();
440+
} catch (err) {
441+
document.getElementById("search-table").innerHTML =
442+
`<tr><td colspan="8" class="muted">불러오기 실패</td></tr>`;
443+
}
370444
try {
371445
const data = await fetchLogs("variants");
372446
state.variants.items = data.items || [];

0 commit comments

Comments
 (0)