InfoLens1 / backend /api /analyze_semantic.py
dqy08's picture
语义分析提示词切到英文;语义分析从「一个接口 + submode/hybrid」拆成两个原生能力——relevance(仅前向门控)与 keywords(归因染色)
1f66b27
Raw History Blame Contribute Delete
13.8 kB
"""Semantic analysis API
原生接口:
- analyze_semantic_relevance:全文相关度(full_match_degree)
- analyze_semantic_keywords:关键词归因(token_attention)
兼容:analyze_semantic(submode=count|fill_blank)
"""
import gc
import json
import queue
import threading
import time
from typing import Literal, Optional
from backend.models.model_manager import inference_lock
from backend.platform.oom import exit_if_oom
from backend.core.semantic_analyzer import (
analyze_relevance,
analyze_relevance_batch,
analyze_keywords,
)
from backend.api.sse_utils import (
SSEProgressReporter,
consume_progress_queue,
send_result_event,
send_error_event,
)
from backend.platform.access_log import get_client_ip
from backend.api.analyze import QueueTimeoutError, ANALYSIS_TIMEOUT, LOCK_WAIT_TIMEOUT
ResponseShape = Literal["legacy", "relevance", "keywords"]
SemanticKind = Literal["relevance", "keywords"]
_LEGACY_SUBMODE = {
"count": "relevance",
"fill_blank": "keywords",
}
def _log_request(query, text, client_ip=None, privacy_mode=False, kind: Optional[SemanticKind] = None):
from backend.platform.access_log import log_analyze_semantic_request
return log_analyze_semantic_request(query, text, client_ip, privacy_mode, kind=kind)
def _build_success_response(
result,
debug_info: bool = False,
shape: ResponseShape = "legacy",
):
"""
shape:
- legacy:两字段都带(relevance 补 attention=[];keywords 补 degree=1)
- relevance:只带 full_match_degree
- keywords:只带 token_attention
"""
if isinstance(result, list):
return {
"success": True,
"results": [
{
"model": r["model"],
"full_match_degree": r["full_match_degree"],
"input_token_count": r["input_token_count"],
}
for r in result
],
}
resp = {
"success": True,
"model": result["model"],
"input_token_count": result["input_token_count"],
}
if shape == "relevance":
resp["full_match_degree"] = result["full_match_degree"]
elif shape == "keywords":
resp["token_attention"] = result["token_attention"]
else:
# legacy:原生结果缺哪边补哪边
resp["full_match_degree"] = result["full_match_degree"] if "full_match_degree" in result else 1.0
resp["token_attention"] = result["token_attention"] if "token_attention" in result else []
if debug_info and "debug_info" in result:
resp["debug_info"] = result["debug_info"]
return resp
def _run_core(kind: SemanticKind, query: str, text, progress_callback, debug_info: bool):
if kind == "relevance":
if isinstance(text, list):
return analyze_relevance_batch(query, text, progress_callback=progress_callback)
return analyze_relevance(
query, text, progress_callback=progress_callback, debug_info=debug_info,
)
if isinstance(text, list):
raise ValueError("keywords does not support batch texts")
return analyze_keywords(
query, text, progress_callback=progress_callback, debug_info=debug_info,
)
def _generate_semantic_events(
query: str,
text,
kind: SemanticKind,
debug_info: bool = False,
client_ip: Optional[str] = None,
request_id: Optional[int] = None,
shape: ResponseShape = "legacy",
):
"""流式语义分析核心:生成 SSE 事件流(progress + result/error)。"""
if client_ip is None:
client_ip = get_client_ip()
start_time = time.perf_counter()
log_text = text if isinstance(text, str) else f"[批量 x{len(text)}] " + text[0]
if request_id is None:
request_id = _log_request(query, log_text, client_ip, kind=kind)
progress_queue = queue.Queue()
analysis_done = threading.Event()
analysis_result = None
analysis_error = None
lock_wait_time = None
def progress_callback(step: int, total_steps: int, stage: str, percentage: Optional[int]):
progress_queue.put(("progress", step, total_steps, stage, percentage))
def run_analysis():
nonlocal analysis_result, analysis_error, lock_wait_time
try:
lock_wait_start = time.perf_counter()
lock_acquired = inference_lock.acquire(timeout=LOCK_WAIT_TIMEOUT)
if not lock_acquired:
analysis_error = QueueTimeoutError(
f"Queue wait exceeded {LOCK_WAIT_TIMEOUT} seconds; server busy, try again later"
)
return
lock_wait_time = time.perf_counter() - lock_wait_start
try:
from backend.platform.access_log import log_analyze_semantic_start
log_analyze_semantic_start(request_id, lock_wait_time, stream_mode=True, kind=kind)
analysis_result = _run_core(kind, query, text, progress_callback, debug_info)
finally:
inference_lock.release()
except Exception as e:
analysis_error = e
finally:
analysis_done.set()
progress_queue.put(("done", None, None))
try:
analysis_thread = threading.Thread(target=run_analysis, daemon=True)
analysis_thread.start()
timeout_reached = False
for kind_ev, event_str in consume_progress_queue(
progress_queue, analysis_done, start_time, ANALYSIS_TIMEOUT, "语义分析"
):
if kind_ev == 'timeout':
timeout_reached = True
yield event_str
break
if kind_ev == 'progress':
yield event_str
elif kind_ev == 'done':
break
if timeout_reached:
gc.collect()
return
if analysis_error:
if isinstance(analysis_error, QueueTimeoutError):
print(f"⏱️ 排队超时: {analysis_error}")
yield send_error_event(str(analysis_error), 503)
gc.collect()
return
raise analysis_error
if analysis_result is None:
print("⚠️ 语义分析结果为空,但没有错误信息")
yield send_error_event("Analysis failed: no result", 500)
gc.collect()
return
elapsed = time.perf_counter() - start_time
from backend.platform.inference_response_log import log_analyze_semantic_response
from backend.platform.model_routing import is_worker
if isinstance(analysis_result, dict):
log_result = analysis_result
else:
total_tokens = sum(r.get("input_token_count", 0) for r in analysis_result)
log_result = {"input_token_count": total_tokens}
log_analyze_semantic_response(
request_id=request_id,
result=log_result,
elapsed=elapsed,
wait_time=lock_wait_time,
for_worker=is_worker(),
kind=kind,
)
yield send_result_event(_build_success_response(analysis_result, debug_info, shape=shape))
except Exception as e:
import traceback
traceback.print_exc()
exit_if_oom(e, defer_seconds=1)
yield send_error_event(str(e), 500)
finally:
gc.collect()
def _analyze_semantic_with_stream(
query: str, text, kind: SemanticKind, debug_info: bool = False,
client_ip: Optional[str] = None, request_id: Optional[int] = None,
shape: ResponseShape = "legacy",
):
return SSEProgressReporter(
lambda: _generate_semantic_events(
query, text, kind, debug_info, client_ip, request_id, shape=shape,
)
).create_response()
def _analyze_semantic_plain(
query: str, text, kind: SemanticKind, debug_info: bool = False,
client_ip: Optional[str] = None, request_id: Optional[int] = None,
shape: ResponseShape = "legacy",
):
result = None
error_msg = None
status_code = 500
try:
for event_str in _generate_semantic_events(
query, text, kind, debug_info, client_ip, request_id, shape=shape,
):
if not event_str.startswith('data: '):
continue
data = json.loads(event_str[6:].strip())
t = data.get('type')
if t == 'result':
result = data.get('data')
elif t == 'error':
error_msg = data.get('message', 'Analysis failed')
status_code = data.get('status_code', 500)
break
except Exception as e:
import traceback
traceback.print_exc()
exit_if_oom(e, defer_seconds=1)
error_msg = str(e)
finally:
gc.collect()
if error_msg:
return {"success": False, "message": error_msg}, status_code
if result is None:
return {"success": False, "message": "Analysis failed: no result"}, 500
return result, 200
def _ingress_semantic(
semantic_request: dict,
*,
api_path: str,
kind: SemanticKind,
content,
log_text: str,
shape: ResponseShape,
):
query = (semantic_request.get("query") or "")
stream = semantic_request.get("stream", False)
debug_info = bool(semantic_request.get("debug_info", False))
privacy_mode = bool(semantic_request.get("privacy_mode", False))
client_ip = get_client_ip()
logged: dict = {"request_id": None}
def log_fn():
logged["request_id"] = _log_request(query, log_text, client_ip, privacy_mode, kind=kind)
def local_fn():
rid = logged["request_id"]
if stream:
return _analyze_semantic_with_stream(
query, content, kind, debug_info, client_ip, rid, shape=shape,
)
return _analyze_semantic_plain(
query, content, kind, debug_info, client_ip, rid, shape=shape,
)
from backend.models.model_manager import ModelSlot
from backend.platform.inference_ingress import ingress_inference
from backend.platform.inference_response_log import make_analyze_semantic_response_logger
from backend.platform.model_routing import is_local
return ingress_inference(
slot=ModelSlot.INSTRUCT,
api_path=api_path,
json_body=semantic_request,
stream=bool(stream),
timeout=60.0,
log_fn=log_fn,
local_fn=local_fn,
response_log_fn=(
None
if is_local(ModelSlot.INSTRUCT)
else make_analyze_semantic_response_logger(logged, kind=kind)
),
)
def _parse_text_or_texts(semantic_request, *, allow_texts: bool):
"""返回 (content, log_text) 或 (None, error_response)。"""
query = (semantic_request.get("query") or "")
if not query:
return None, ({"success": False, "message": "Missing query"}, 400)
texts = semantic_request.get("texts")
text = semantic_request.get("text") or ""
if texts is not None:
if not allow_texts:
return None, ({"success": False, "message": "texts is not supported on this endpoint"}, 400)
if not isinstance(texts, list) or not texts or not all(isinstance(t, str) and t for t in texts):
return None, ({"success": False, "message": "texts must be a non-empty string array"}, 400)
return texts, f"[批量 x{len(texts)}] " + texts[0]
if not text:
return None, ({"success": False, "message": "Missing text"}, 400)
return text, text
def analyze_semantic_relevance(semantic_request):
"""全文相关度:只返回 full_match_degree(可批量 texts)。"""
content, log_or_err = _parse_text_or_texts(semantic_request, allow_texts=True)
if content is None:
return log_or_err
return _ingress_semantic(
semantic_request,
api_path="/api/analyze-semantic-relevance",
kind="relevance",
content=content,
log_text=log_or_err,
shape="relevance",
)
def analyze_semantic_keywords(semantic_request):
"""关键词归因:只返回 token_attention(单条 text)。"""
content, log_or_err = _parse_text_or_texts(semantic_request, allow_texts=False)
if content is None:
return log_or_err
return _ingress_semantic(
semantic_request,
api_path="/api/analyze-semantic-keywords",
kind="keywords",
content=content,
log_text=log_or_err,
shape="keywords",
)
def analyze_semantic(semantic_request):
"""
兼容旧接口:submode=count|fill_blank(映射到 relevance|keywords)。
texts 批量仅支持 count(未传 submode 时按 count)。
"""
texts = semantic_request.get("texts")
submode = (semantic_request.get("submode") or "").strip() or None
if submode == "match_score":
return {"success": False, "message": "submode match_score has been removed"}, 400
if texts is not None:
if submode and submode != "count":
return {"success": False, "message": "texts batch mode only supports submode=count"}, 400
kind: SemanticKind = "relevance"
else:
mapped = _LEGACY_SUBMODE.get(submode or "fill_blank")
if mapped is None:
return {"success": False, "message": f"Unknown submode: {submode}"}, 400
kind = mapped # type: ignore[assignment]
content, log_or_err = _parse_text_or_texts(semantic_request, allow_texts=True)
if content is None:
return log_or_err
return _ingress_semantic(
semantic_request,
api_path="/api/analyze-semantic",
kind=kind,
content=content,
log_text=log_or_err,
shape="legacy",
)