feat: HSAP platform v2 — modular navigation, quality review, audit log, world model simulation

Major changes:
- New frontend (platform/web/): Vite + React 18 + TypeScript + Tailwind
- 4-module navigation: 数据送标 / 模型管理 / 车队管理 / 系统管理
- Data catalog with charts (DMS/ADAS/Lane 3-tab view)
- Quality review workflow (标注质检): Good/Fine/Bad scoring with auto-advance
- Audit enhancements: batch operations, rejection categories, Feishu notifications
- Operation audit log (操作日志)
- World model simulation studio (仿真工坊)
- Dataset version management with snapshots and diff
- ADAS 7-class dataset integration (138K images organized + compressed)
- User management with Feishu integration and pagination
- CRUD/search/filter on all pages, card layout redesign
- PIL-optimized image overlay rendering
- Auto-snapshot on build, in_review workflow stage
- Removed embedded algorithm code (now in workspace)
This commit is contained in:
2026-06-03 11:40:21 +08:00
parent 7c43b44c57
commit e72bc061c5
5487 changed files with 979207 additions and 6197 deletions

View File

@@ -0,0 +1 @@
"""外部系统集成。"""

View File

@@ -0,0 +1,120 @@
"""平台送标申请审批通过后analyze → promote 入湖。"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from as_platform.data.lake import (
analyze_directory_candidate,
create_directory_candidate,
promote_candidate_to_inbox,
)
from as_platform.db.engine import session_scope
from as_platform.db.models import BatchDelivery
def validate_delivery_fields(
*,
project: str,
task: str | None,
mode: str | None,
batch_name: str,
data_path: str,
) -> str | None:
if not project or not batch_name:
return "缺少 项目 或 批次名"
if batch_name == (task or ""):
return "批次名不能与任务名相同"
if task in ("dam", "forward") and not (mode or "").strip():
return f"任务 {task} 须填写 子模式"
path = (data_path or "").strip()
if not path:
return "须填写 数据路径"
p = Path(path)
if not p.exists():
return f"数据路径不存在: {path}"
if project == "dms" and p.name in ("train", "val", "test") and p.parent.name == "images":
return f"请填批次根目录(例如 {p.parent.parent}),不要填到 images/train"
return None
def ingest_from_directory(
*,
project: str,
task: str | None,
mode: str | None,
batch_name: str,
data_path: str,
delivery_id: str | None = None,
) -> dict[str, Any]:
"""目录分析并 promote 到 inbox。"""
err = validate_delivery_fields(
project=project,
task=task,
mode=mode,
batch_name=batch_name,
data_path=data_path,
)
if err:
raise ValueError(err)
src = Path(data_path.strip())
task_eff = task if project == "dms" else None
cand = create_directory_candidate(
project=project,
task=task_eff,
mode=mode or None,
source_dir=src,
source_type="platform_delivery",
external_id=delivery_id,
)
cid = cand["id"]
analyze_directory_candidate(cid, src)
promo = promote_candidate_to_inbox(cid, batch=batch_name, mode=mode or None)
return {
"ok": True,
"candidate_id": cid,
"inbox_path": promo.get("inbox_path") or "",
"batch": promo.get("batch") or batch_name,
}
def run_delivery_ingest(delivery_id: str) -> dict[str, Any]:
with session_scope() as db:
rec = db.get(BatchDelivery, delivery_id)
if not rec:
raise ValueError(f"送标申请不存在: {delivery_id}")
rec.status = "ingesting"
rec.error_message = None
db.flush()
project = rec.project
task = rec.task
mode = rec.mode
batch_name = rec.batch_name
data_path = rec.data_path
try:
result = ingest_from_directory(
project=project,
task=task,
mode=mode,
batch_name=batch_name,
data_path=data_path,
delivery_id=delivery_id,
)
except Exception as e:
from as_platform.deliveries.service import mark_delivery_ingest_failed
mark_delivery_ingest_failed(delivery_id, None, str(e))
raise
with session_scope() as db:
rec = db.get(BatchDelivery, delivery_id)
if rec:
rec.status = "in_lake"
rec.candidate_id = result.get("candidate_id")
rec.inbox_path = result.get("inbox_path")
rec.error_message = None
db.flush()
return result

View File

@@ -0,0 +1,299 @@
"""飞书多维表格 APItenant_access_token"""
from __future__ import annotations
import time
from datetime import datetime, timezone
from typing import Any
import httpx
from as_platform.auth.feishu import _get_tenant_access_token
from as_platform.config import (
FEISHU_APP_ID,
FEISHU_APP_SECRET,
FEISHU_BITABLE_APP_TOKEN,
FEISHU_BITABLE_FIELDS,
FEISHU_BITABLE_TABLE_ID,
FEISHU_BITABLE_WIKI_NODE_TOKEN,
)
BITABLE_BASE = "https://open.feishu.cn/open-apis/bitable/v1"
WIKI_GET_NODE = "https://open.feishu.cn/open-apis/wiki/v2/spaces/get_node"
_field_cache: dict[str, Any] | None = None
_field_cache_at: float = 0.0
_FIELD_CACHE_TTL = 300.0
_resolved_app_token: str | None = None
def _looks_like_bitable_app_token(token: str) -> bool:
t = token.strip()
return t.startswith(("Basc", "basc", "app"))
def _resolve_app_token_from_wiki(node_token: str) -> str:
with _client() as client:
access = _token(client)
resp = client.get(
WIKI_GET_NODE,
headers={"Authorization": f"Bearer {access}"},
params={"token": node_token},
)
resp.raise_for_status()
data = resp.json()
if data.get("code") != 0:
raise RuntimeError(data.get("msg") or "wiki get_node failed")
node = data.get("data", {}).get("node") or {}
obj = (node.get("obj_token") or "").strip()
if not obj:
raise RuntimeError(
"wiki 节点未返回 obj_token请在开放平台开通 wiki:node:read或改用 /base/Basc... 填 FEISHU_BITABLE_APP_TOKEN"
)
return obj
def _effective_app_token() -> str:
global _resolved_app_token
if FEISHU_BITABLE_APP_TOKEN and _looks_like_bitable_app_token(FEISHU_BITABLE_APP_TOKEN):
return FEISHU_BITABLE_APP_TOKEN
node = FEISHU_BITABLE_WIKI_NODE_TOKEN or FEISHU_BITABLE_APP_TOKEN
if not node:
return FEISHU_BITABLE_APP_TOKEN
if _resolved_app_token is None:
_resolved_app_token = _resolve_app_token_from_wiki(node)
return _resolved_app_token
def is_bitable_configured() -> bool:
has_app = bool(FEISHU_BITABLE_APP_TOKEN or FEISHU_BITABLE_WIKI_NODE_TOKEN)
return bool(FEISHU_APP_ID and FEISHU_APP_SECRET and has_app and FEISHU_BITABLE_TABLE_ID)
def _client() -> httpx.Client:
return httpx.Client(timeout=60.0)
def _token(client: httpx.Client) -> str:
return _get_tenant_access_token(client)
def _app_table() -> tuple[str, str]:
return _effective_app_token(), FEISHU_BITABLE_TABLE_ID
def list_tables() -> list[dict[str, Any]]:
"""列出 Base 下数据表(运维查 table_id"""
if not is_bitable_configured():
return []
app_token, _ = _app_table()
with _client() as client:
token = _token(client)
resp = client.get(
f"{BITABLE_BASE}/apps/{app_token}/tables",
headers={"Authorization": f"Bearer {token}"},
params={"page_size": 100},
)
resp.raise_for_status()
data = resp.json()
if data.get("code") != 0:
raise RuntimeError(data.get("msg") or "list tables failed")
return data.get("data", {}).get("items") or []
def _load_field_meta(force: bool = False) -> dict[str, dict[str, Any]]:
global _field_cache, _field_cache_at
now = time.time()
if not force and _field_cache and now - _field_cache_at < _FIELD_CACHE_TTL:
return _field_cache
app_token, table_id = _app_table()
by_name: dict[str, dict[str, Any]] = {}
with _client() as client:
token = _token(client)
page_token: str | None = None
while True:
params: dict[str, Any] = {"page_size": 100}
if page_token:
params["page_token"] = page_token
resp = client.get(
f"{BITABLE_BASE}/apps/{app_token}/tables/{table_id}/fields",
headers={"Authorization": f"Bearer {token}"},
params=params,
)
resp.raise_for_status()
data = resp.json()
if data.get("code") != 0:
raise RuntimeError(data.get("msg") or "list fields failed")
for item in data.get("data", {}).get("items") or []:
name = item.get("field_name") or item.get("name")
if name:
by_name[str(name)] = item
page_token = data.get("data", {}).get("page_token")
if not page_token:
break
_field_cache = by_name
_field_cache_at = now
return by_name
def _option_id_for_text(field_meta: dict[str, Any], text: str) -> str | None:
ui = field_meta.get("ui_type") or field_meta.get("type")
if ui not in (3, "SingleSelect", "single_select"):
return None
prop = field_meta.get("property") or {}
for opt in prop.get("options") or []:
if opt.get("name") == text:
return opt.get("id")
return None
def _encode_value(field_meta: dict[str, Any], value: Any) -> Any:
if value is None:
return None
ui = field_meta.get("ui_type") or field_meta.get("type")
if ui in (1, "Text", "text", "Url", "url"):
if isinstance(value, dict) and "link" in value:
return value
return str(value)
if ui in (2, "Number", "number"):
try:
return float(value)
except (TypeError, ValueError):
return None
if ui in (3, "SingleSelect", "single_select"):
oid = _option_id_for_text(field_meta, str(value))
return oid if oid else str(value)
if ui in (5, "DateTime", "datetime", "CreatedTime", "ModifiedTime"):
if isinstance(value, (int, float)):
return int(value)
if isinstance(value, datetime):
dt = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
return int(dt.timestamp() * 1000)
return int(datetime.now(timezone.utc).timestamp() * 1000)
if ui in (15, "Url", "url") and isinstance(value, dict):
return {"text": value.get("text") or value.get("link"), "link": value.get("link")}
return str(value)
def _flatten_cell(cell: Any) -> str:
if cell is None:
return ""
if isinstance(cell, str):
return cell.strip()
if isinstance(cell, (int, float)):
return str(cell)
if isinstance(cell, list):
parts = [_flatten_cell(x) for x in cell]
return ",".join(p for p in parts if p)
if isinstance(cell, dict):
if "text" in cell:
return str(cell.get("text") or "").strip()
if "name" in cell:
return str(cell.get("name") or "").strip()
if "value" in cell:
return _flatten_cell(cell.get("value"))
return str(cell).strip()
def list_all_records() -> list[dict[str, Any]]:
"""返回 {record_id, fields_raw, flat}。"""
if not is_bitable_configured():
return []
app_token, table_id = _app_table()
meta = _load_field_meta()
id_by_label = {label: meta[name]["field_id"] for name, m in meta.items() if (label := FEISHU_BITABLE_FIELDS.get(name)) and name in meta for name in FEISHU_BITABLE_FIELDS}
# rebuild: map logical key -> field_name
name_for_key = {k: v for k, v in FEISHU_BITABLE_FIELDS.items()}
rows: list[dict[str, Any]] = []
with _client() as client:
token = _token(client)
page_token: str | None = None
while True:
body: dict[str, Any] = {"page_size": 500}
if page_token:
body["page_token"] = page_token
resp = client.post(
f"{BITABLE_BASE}/apps/{app_token}/tables/{table_id}/records/search",
headers={"Authorization": f"Bearer {token}"},
json=body,
)
resp.raise_for_status()
data = resp.json()
if data.get("code") != 0:
raise RuntimeError(data.get("msg") or "search records failed")
for item in data.get("data", {}).get("items") or []:
record_id = item.get("record_id") or item.get("id")
fields = item.get("fields") or {}
flat: dict[str, str] = {}
for key, col_name in name_for_key.items():
raw = fields.get(col_name)
if raw is None:
fid = meta.get(col_name, {}).get("field_id")
if fid:
raw = fields.get(fid)
flat[key] = _flatten_cell(raw)
rows.append({"record_id": record_id, "fields": fields, "flat": flat})
page_token = data.get("data", {}).get("page_token")
if not page_token:
break
return rows
def update_record(record_id: str, values: dict[str, Any]) -> None:
"""values: 逻辑键 delivery_id / status / inbox_path ..."""
if not record_id or not is_bitable_configured():
return
meta = _load_field_meta()
payload_fields: dict[str, Any] = {}
for key, val in values.items():
if val is None:
continue
col = FEISHU_BITABLE_FIELDS.get(key)
if not col or col not in meta:
continue
encoded = _encode_value(meta[col], val)
if encoded is not None:
payload_fields[col] = encoded
if not payload_fields:
return
app_token, table_id = _app_table()
with _client() as client:
token = _token(client)
resp = client.put(
f"{BITABLE_BASE}/apps/{app_token}/tables/{table_id}/records/{record_id}",
headers={"Authorization": f"Bearer {token}"},
json={"fields": payload_fields},
)
resp.raise_for_status()
data = resp.json()
if data.get("code") != 0:
raise RuntimeError(data.get("msg") or "update record failed")
def connectivity_check() -> dict[str, Any]:
if not is_bitable_configured():
return {"configured": False, "ok": False, "message": "缺少 FEISHU_BITABLE_APP_TOKEN 或 TABLE_ID"}
try:
tables = list_tables()
meta = _load_field_meta(force=True)
missing = [v for k, v in FEISHU_BITABLE_FIELDS.items() if k not in ("record_id",) and v not in meta]
return {
"configured": True,
"ok": len(missing) == 0,
"tables": [{"table_id": t.get("table_id"), "name": t.get("name")} for t in tables],
"field_count": len(meta),
"missing_columns": missing,
}
except Exception as e:
msg = str(e)
hint = ""
if "1254302" in msg or "no permissions" in msg.lower():
hint = (
"飞书返回 1254302应用「主动安全算法平台」对该表无读写权。"
"知识库内嵌表常无法加企业应用,请复制为独立多维表格(/base/Basc...)并加应用协作者,"
"或见 docs/FEISHU_BITABLE_OPS.md §8联调可设 FEISHU_BITABLE_SYNC_ENABLED=0。"
)
return {"configured": True, "ok": False, "message": msg, "hint": hint or None}

View File

@@ -0,0 +1,158 @@
"""飞书多维表格「待落盘」→ analyze → promotePhase BFEISHU_BITABLE_AUTO_INGEST"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from as_platform.config import FEISHU_BITABLE_FIELDS
from as_platform.data.lake import (
analyze_directory_candidate,
create_feishu_directory_candidate,
promote_candidate_to_inbox,
)
from as_platform.db.engine import session_scope
from as_platform.db.models import DatasetCandidate, FeishuBitableLink
from as_platform.integrations.feishu_bitable import is_bitable_configured, list_all_records, update_record
from as_platform.integrations.feishu_bitable_sync import batch_key
def _validate_row(flat: dict[str, str]) -> str | None:
project = flat.get("project") or ""
task = flat.get("task") or ""
batch_name = flat.get("batch_name") or ""
mode = flat.get("mode") or ""
if not project or not batch_name:
return "缺少 项目 或 批次名"
if batch_name == task:
return "批次名不能与任务名相同"
if task in ("dam", "forward") and not mode:
return f"任务 {task} 须填写 子模式"
data_path = (flat.get("data_path") or "").strip()
if not data_path:
return "待落盘须填写 数据路径(内网 NAS"
if not Path(data_path).exists():
return f"数据路径不存在: {data_path}"
return None
def _already_ingested(flat: dict[str, str], delivery_id: str) -> bool:
cid = (flat.get("candidate_id") or "").strip()
if cid:
with session_scope() as db:
rec = db.get(DatasetCandidate, cid)
if rec and rec.status in ("promoted", "analyzed") and rec.inbox_path:
return True
if delivery_id:
with session_scope() as db:
link = db.query(FeishuBitableLink).filter_by(delivery_id=delivery_id).first()
if link and link.inbox_path:
return True
return False
def process_pending_ingest() -> dict[str, Any]:
if not is_bitable_configured():
return {"ok": False, "message": "未配置多维表格", "processed": 0}
records = list_all_records()
processed = 0
skipped = 0
errors: list[str] = []
for rec in records:
record_id = rec.get("record_id")
flat = rec.get("flat") or {}
if not record_id:
continue
if flat.get("status") != "待落盘":
continue
delivery_id = flat.get("delivery_id") or ""
if _already_ingested(flat, delivery_id):
skipped += 1
continue
err = _validate_row(flat)
if err:
try:
update_record(record_id, {"status": "落盘失败", "error_message": err})
except Exception as e:
errors.append(f"{record_id}: {e}")
continue
try:
update_record(record_id, {"status": "分析中", "error_message": ""})
except Exception:
pass
project = flat.get("project") or "dms"
task = flat.get("task") or None
mode = flat.get("mode") or None
batch_name = flat.get("batch_name") or ""
src = Path((flat.get("data_path") or "").strip())
try:
cand = create_feishu_directory_candidate(
project=project,
task=task if project == "dms" else None,
mode=mode or None,
source_dir=src,
external_id=delivery_id or None,
feishu_record_id=record_id,
)
cid = cand["id"]
analyze_directory_candidate(cid, src)
promo = promote_candidate_to_inbox(cid, batch=batch_name, mode=mode or None)
inbox_path = promo.get("inbox_path") or ""
update_record(
record_id,
{
"status": "待送标",
"candidate_id": cid,
"inbox_path": inbox_path,
"error_message": "",
"record_id": record_id,
},
)
key = batch_key(
project,
task if project == "dms" else None,
mode,
promo.get("batch") or batch_name,
)
with session_scope() as db:
link = db.query(FeishuBitableLink).filter_by(batch_key=key).first()
if not link:
link = FeishuBitableLink(
batch_key=key,
record_id=record_id,
delivery_id=delivery_id or None,
project=project,
task=task,
mode=mode,
batch=promo.get("batch") or batch_name,
)
db.add(link)
link.record_id = record_id
link.delivery_id = delivery_id or link.delivery_id
link.inbox_path = inbox_path
db.flush()
processed += 1
except Exception as e:
msg = str(e)
errors.append(f"{record_id}: {msg}")
try:
update_record(record_id, {"status": "落盘失败", "error_message": msg[:500]})
except Exception:
pass
return {
"ok": True,
"processed": processed,
"skipped": skipped,
"errors": errors[:20],
"status_field": FEISHU_BITABLE_FIELDS.get("status"),
}

View File

@@ -0,0 +1,236 @@
"""HSAP 批次 → 飞书多维表格回写(内网 Phase A"""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from as_platform.config import FEISHU_STATUS_FROM_STAGE, FRONTEND_URL
from as_platform.data.core import get_pending_report
from as_platform.db.engine import session_scope
from as_platform.db.models import FeishuBitableLink
from as_platform.integrations.feishu_bitable import is_bitable_configured, list_all_records, update_record
from as_platform.labeling.progress import campaign_progress_summary
from as_platform.labeling.service import list_labeling_batches
def batch_key(project: str, task: str | None, mode: str | None, batch: str, location: str = "inbox") -> str:
return f"{location}:{project}:{task or ''}:{mode or ''}:{batch}"
def _collect_hsap_batches() -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
seen: set[str] = set()
try:
for row in list_labeling_batches().get("items") or []:
key = batch_key(
row.get("project") or "dms",
row.get("task"),
row.get("mode"),
row.get("batch") or "",
row.get("location") or "inbox",
)
if key in seen:
continue
seen.add(key)
items.append(row)
except Exception:
pass
try:
report = get_pending_report()
for row in report.get("batches") or []:
if row.get("stage") == "ingested":
continue
key = batch_key(
row.get("project") or "dms",
row.get("task"),
row.get("mode"),
row.get("batch") or "",
row.get("location") or "inbox",
)
if key in seen:
continue
seen.add(key)
items.append(row)
except Exception:
pass
return items
def _find_batch_for_record(
flat: dict[str, str],
hsap_batches: list[dict[str, Any]],
links_by_delivery: dict[str, FeishuBitableLink],
) -> dict[str, Any] | None:
did = (flat.get("delivery_id") or "").strip()
if did and did in links_by_delivery:
key = links_by_delivery[did].batch_key
for b in hsap_batches:
if batch_key(
b.get("project") or "dms",
b.get("task"),
b.get("mode"),
b.get("batch") or "",
b.get("location") or "inbox",
) == key:
return b
for b in hsap_batches:
if _match_record_to_batch(flat, b):
return b
return None
def _match_record_to_batch(flat: dict[str, str], b: dict[str, Any]) -> bool:
if flat.get("project") and flat.get("project") != (b.get("project") or ""):
return False
if flat.get("task") and flat.get("task") != (b.get("task") or ""):
return False
bm = flat.get("batch_name") or ""
if bm and bm != (b.get("batch") or ""):
return False
fm = flat.get("mode") or ""
if fm and fm != (b.get("mode") or ""):
return False
if not bm and not fm:
return False
return True
def _progress_for_batch(b: dict[str, Any]) -> tuple[str, str | None]:
cid = b.get("campaign_id")
if not cid:
return "", None
try:
prog = campaign_progress_summary(cid)
return f"{prog.get('completed_tasks', 0)}/{prog.get('total_tasks', 0)}", cid
except Exception:
return "", cid
def _hsap_link(b: dict[str, Any], campaign_id: str | None) -> dict[str, str]:
base = FRONTEND_URL.rstrip("/")
if campaign_id:
path = f"/labeling/campaigns/{campaign_id}/annotate"
label = "进入标注"
else:
path = "/labeling"
label = "送标工作台"
url = f"{base}{path}"
batch = b.get("batch") or ""
return {"text": f"{label} · {batch}", "link": url}
def sync_hsap_to_bitable() -> dict[str, Any]:
if not is_bitable_configured():
return {"ok": False, "message": "飞书多维表格未配置", "updated": 0}
hsap_batches = _collect_hsap_batches()
records = list_all_records()
updated = 0
matched_keys: set[str] = set()
errors: list[str] = []
with session_scope() as db:
links = db.query(FeishuBitableLink).all()
links_by_delivery = {lnk.delivery_id: lnk for lnk in links if lnk.delivery_id}
now = datetime.now(timezone.utc)
for rec in records:
record_id = rec.get("record_id")
flat = rec.get("flat") or {}
if not record_id:
continue
hit = _find_batch_for_record(flat, hsap_batches, links_by_delivery)
if not hit:
continue
key = batch_key(
hit.get("project") or "dms",
hit.get("task"),
hit.get("mode"),
hit.get("batch") or "",
hit.get("location") or "inbox",
)
matched_keys.add(key)
stage = hit.get("stage") or "raw_pool"
feishu_status = FEISHU_STATUS_FROM_STAGE.get(stage)
progress, cid = _progress_for_batch(hit)
path = hit.get("path") or ""
payload: dict[str, Any] = {
"record_id": record_id,
"inbox_path": path,
"progress": progress or None,
"campaign_id": cid,
"hsap_link": _hsap_link(hit, cid),
"last_sync": now,
}
if feishu_status:
payload["status"] = feishu_status
try:
update_record(record_id, payload)
updated += 1
with session_scope() as db:
link = db.query(FeishuBitableLink).filter_by(batch_key=key).first()
if not link:
link = FeishuBitableLink(
batch_key=key,
record_id=record_id,
project=hit.get("project") or "dms",
task=hit.get("task"),
mode=hit.get("mode"),
batch=hit.get("batch") or "",
)
db.add(link)
link.record_id = record_id
link.delivery_id = flat.get("delivery_id") or link.delivery_id
link.campaign_id = cid
link.inbox_path = path
link.last_sync_at = now
db.flush()
except Exception as e:
errors.append(f"{record_id}: {e}")
return {
"ok": True,
"updated": updated,
"hsap_batches": len(hsap_batches),
"bitable_rows": len(records),
"matched": len(matched_keys),
"errors": errors[:20],
}
def backfill_hints() -> dict[str, Any]:
"""返回尚未在飞书表匹配到的 HSAP 批次(需人工补录行)。"""
hsap_batches = _collect_hsap_batches()
records = list_all_records() if is_bitable_configured() else []
unmatched: list[dict[str, Any]] = []
for b in hsap_batches:
found = False
for rec in records:
if _match_record_to_batch(rec.get("flat") or {}, b):
found = True
break
if not found:
unmatched.append(
{
"project": b.get("project"),
"task": b.get("task"),
"mode": b.get("mode"),
"batch": b.get("batch"),
"stage": b.get("stage"),
"path": b.get("path"),
"suggested_batch_name": b.get("batch"),
}
)
return {"unmatched_count": len(unmatched), "items": unmatched[:100]}

View File

@@ -0,0 +1,68 @@
"""飞书群消息通知(出站,内网可用)。"""
from __future__ import annotations
import json
from typing import Any
import httpx
from as_platform.auth.feishu import _get_tenant_access_token
from as_platform.config import FEISHU_APP_ID, FEISHU_APP_SECRET, FEISHU_LABELING_CHAT_ID
IM_MSG_URL = "https://open.feishu.cn/open-apis/im/v1/messages"
def is_notify_configured() -> bool:
return bool(FEISHU_APP_ID and FEISHU_APP_SECRET and FEISHU_LABELING_CHAT_ID)
def send_chat_text(text: str) -> dict[str, Any]:
if not is_notify_configured():
return {"ok": False, "message": "未配置 FEISHU_LABELING_CHAT_ID"}
with httpx.Client(timeout=30.0) as client:
token = _get_tenant_access_token(client)
resp = client.post(
IM_MSG_URL,
params={"receive_id_type": "chat_id"},
headers={"Authorization": f"Bearer {token}"},
json={
"receive_id": FEISHU_LABELING_CHAT_ID,
"msg_type": "text",
"content": json.dumps({"text": text}, ensure_ascii=False),
},
)
resp.raise_for_status()
data = resp.json()
if data.get("code") != 0:
return {"ok": False, "message": data.get("msg") or "send failed"}
return {"ok": True}
def send_chat_async(text: str) -> None:
"""异步发送飞书消息,不阻塞主流程。"""
import threading
threading.Thread(target=_send_safe, args=(text,), daemon=True, name="feishu-notify").start()
def _send_safe(text: str) -> None:
try:
send_chat_text(text)
except Exception:
pass # 通知失败不影响业务流程
def notify_batch_progress(
*,
delivery_id: str,
task: str,
batch_name: str,
progress: str,
link: str,
) -> dict[str, Any]:
text = (
f"[HSAP] 标注进度 {delivery_id or batch_name}\n"
f"任务: {task} / 批次: {batch_name}\n"
f"进度: {progress}\n"
f"链接: {link}"
)
return send_chat_text(text)