"""Small renovation-only rules pilot over a frozen, evidence-backed search run.""" from __future__ import annotations import json import re import unicodedata from collections import Counter from copy import deepcopy from urllib.parse import urlsplit from fastapi import HTTPException from pydantic import Field, model_validator from .db import encoded, identifier, now from .learning_contracts import ClosedInput from .worker import safe_error_codes class DiscoveryInput(ClosedInput): search_candidate_id: str = Field(min_length=1, max_length=120) keyword: str = Field(min_length=1, max_length=2000) service: str = Field(default="装修服务", min_length=1, max_length=120) target_customer: str = Field(default="有装修需求的业主", min_length=1, max_length=300) region: str = Field(default="", max_length=120) max_selected: int = Field(default=2, ge=1, le=3, strict=True) @model_validator(mode="after") def ordinary_conditions(self): if not re.fullmatch(r"[A-Za-z0-9_-]+", self.search_candidate_id): raise ValueError("invalid candidate reference") for key in ("keyword", "service", "target_customer", "region"): setattr(self, key, getattr(self, key).strip()) if not self.keyword or not self.service or not self.target_customer: raise ValueError("business conditions cannot be blank") if self.service != "装修服务" or self.target_customer != "有装修需求的业主": raise ValueError("only the current renovation owner discovery scope is supported") return self class BusinessDiscoveryInput(ClosedInput): search_candidate_id: str = Field(min_length=1, max_length=120, pattern=r"^[A-Za-z0-9_-]+$") max_selected: int = Field(default=2, ge=1, le=3, strict=True) RENOVATION = re.compile(r"装修|装潢|家装|旧房|新房|毛坯|硬装|软装|全屋|改造|验房|水电|翻新") HELP = re.compile(r"求助|求推荐|有没有.{0,12}推荐|准备.{0,5}装修|打算.{0,5}装修|想.{0,5}装修|要装修|装修小白|装修新手|找.{0,10}(?:工长|师傅|装修公司|设计师)|求.{0,10}(?:公司|工长|报价)|我家.{0,8}(?:装|改造)") CASE = re.compile(r"案例|设计师|装修公司|工长|实景|作品|工地|接单") FINISHED = re.compile(r"(? tuple | None: try: parsed = urlsplit(value) if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password: return None return parsed.scheme, parsed.hostname.lower(), parsed.port or (443 if parsed.scheme == "https" else 80) except (ValueError, TypeError): return None def note_reference(value, entry_url: str) -> tuple[str, str] | None: if not isinstance(value, str) or origin(value) != origin(entry_url): return None parsed = urlsplit(value) match = re.fullmatch(r"/(?:explore|search_result)/([a-fA-F0-9]{24})", parsed.path) if not match: return None entry = urlsplit(entry_url) note_id = match[1].lower() return note_id, f"{entry.scheme}://{entry.netloc}/explore/{note_id}" def title_key(value: str) -> str: return " ".join(unicodedata.normalize("NFKC", value).split()).casefold() def select_results(items: list[dict], entry_url: str, max_selected: int) -> tuple[list[dict], list[dict]]: title_counts = Counter(title_key(item["title"]) for item in items if isinstance(item.get("title"), str) and item["title"].strip()) results, by_id, scores = [], {}, {} for index, item in enumerate(items, 1): title = item.get("title") if isinstance(item.get("title"), str) else None author = item.get("author") if isinstance(item.get("author"), str) else None reference = note_reference(item.get("url"), entry_url) row = {"index": index, "title": title, "author": author, "url": reference[1] if reference else None, "canonical_url": reference[1] if reference else None, "note_id": reference[0] if reference else None, "classification": "invalid", "selected": False, "reasons": [], "uncertainties": list(UNCERTAINTIES)} if reference and reference[0] in by_id: first = by_id[reference[0]] first["reasons"].append("同一笔记 ID 的重复结果已合并") # Different card texts for one object are not a sound click target. if title_key(title or "") != title_key(first["title"] or ""): first["classification"] = "ambiguous" first["reasons"].append("同一笔记出现不同标题,需要人工核对") continue results.append(row) if not reference: row["reasons"].append("来源不是候选同站的真实笔记路径,未选择") continue by_id[reference[0]] = row if not title or not title.strip(): row["classification"] = "ambiguous" row["reasons"].append("当前未读到标题,不能安全按标题选择") elif "[已隐藏]" in title or "[手机号已隐藏]" in title: row["classification"] = "ambiguous" row["reasons"].append("标题含脱敏替代文字,不能作为精确点击依据") elif title_counts[title_key(title)] > 1: row["classification"] = "ambiguous" row["reasons"].append("标题重复,无法唯一定位笔记,未自动选择") elif not RENOVATION.search(title): row["classification"] = "unrelated" row["reasons"].append("标题未出现装修相关信号,不推定采购需求") elif FINISHED.search(title): row["classification"] = "peer_case" row["reasons"].append("存在已完工或入住分享信号,保留评论来源,不判定为待装修需求") scores[index] = 60 elif HELP.search(title): row["classification"] = "consumer_help" row["reasons"].append("标题出现装修求助或准备装修表述,优先进一步阅读;不确认真实业主身份") scores[index] = 100 elif CASE.search(title): row["classification"] = "peer_case" row["reasons"].append("存在案例或服务展示信号,保留作为评论线索来源;不确认作者为商家") scores[index] = 60 else: row["classification"] = "related_content" row["reasons"].append("标题与装修相关,可进一步查看;相关或好看不等于需求") scores[index] = 40 ranked = sorted((item for item in results if item["classification"] in {"consumer_help", "peer_case", "related_content"}), key=lambda item: (-scores[item["index"]], item["index"])) selected = [] for row in ranked[:max_selected]: row["selected"] = True selected.append({"note_title": row["title"], "note_url": row["canonical_url"], "note_id": row["note_id"], "reason": row["reasons"][0]}) for row in ranked[max_selected:]: row["reasons"].append("达到本次保留数量上限,本轮未保留用于后续详情读取") return results, selected class DiscoveryService: def __init__(self, learning): self.learning, self.store = learning, learning.store def get(self, run_id: str, owner_id: str) -> dict: row = self.store.one("SELECT * FROM discovery_runs WHERE id=$1 AND owner_id=$2", (run_id, owner_id)) if not row: raise HTTPException(404, "发现记录不存在") return {"id": row["id"], "created_at": row["created_at"], **json.loads(row["data_json"])} def list(self, owner_id: str) -> list[dict]: return [self.get(row["id"], owner_id) for row in self.store.rows(f"SELECT id FROM discovery_runs WHERE owner_id=$1 ORDER BY {self.store.order_column()} DESC", (owner_id,))] @staticmethod def supported_candidate(candidate: dict) -> bool: manifest = candidate["manifest"] entry_origin = origin(manifest.get("entry_url", "")) platform = manifest.get("platform") supported_origin = (platform == "xiaohongshu" and entry_origin == ("https", "www.xiaohongshu.com", 443)) or (platform == "demo" and entry_origin and entry_origin[1] in {"127.0.0.1", "localhost", "::1"}) steps = manifest.get("steps") or [] if not supported_origin or len(steps) != 4 or [step.get("kind") for step in steps] != ["fill", "press", "wait", "extract"]: return False if "keyword" not in manifest.get("parameters", {}) or steps[0].get("parameter") != "keyword" or steps[1].get("key") != "Enter" or steps[2].get("state") != "visible" or type(steps[2].get("min_count")) is not int or steps[2]["min_count"] < 1: return False output = steps[3] fields = {item["name"]: item for item in output.get("fields", [])} return (output.get("output_name") == "search_results" and set(fields) == {"title", "author", "url"} and all(fields[name].get("source", "text") == "text" and not fields[name].get("required") for name in ("title", "author")) and fields["url"].get("source") == "href" and fields["url"].get("required") is True) async def create_for_brief(self, owner_id: str, brief: dict, values: dict) -> dict: """Use one explicitly confirmed snapshot; never accept condition overrides.""" conditions = brief["conditions"] inputs = DiscoveryInput(search_candidate_id=values["search_candidate_id"], max_selected=values["max_selected"], keyword=conditions["search_terms"][0], service=conditions["service"], target_customer=conditions["target_customer"], region=conditions["region"] or "") return await self.create(owner_id, inputs.model_dump(), business_brief=brief) async def create(self, owner_id: str, values: dict, *, business_brief: dict | None = None) -> dict: candidate = self.learning.candidate(values["search_candidate_id"], owner_id) content = {"status": "failed", "analysis_mode": "rules", "model_connected": False, "rule_scope": "renovation_only", "search_candidate_id": candidate["id"], "search_task_id": candidate["task_id"], "candidate_digest": candidate["digest"], "validation_id": None, "keyword": values["keyword"], "conditions": {key: values[key] for key in ("service", "target_customer", "region")}, "max_selected": values["max_selected"], "results": [], "selected": [], "evidence": [], "errors": [], "error_codes": []} if business_brief is not None: content.update(business_brief_id=business_brief["id"], business_brief_digest=business_brief["digest"], business_search_term_index=0) if not self.supported_candidate(candidate): content.update(errors=["当前候选不符合已支持的装修搜索合同"], error_codes=["DISCOVERY_CONTRACT_INVALID"]) else: try: validation = await self.learning.verify(candidate["id"], owner_id, {"mode": "exploration", "parameters": {"keyword": values["keyword"]}, "expected_outputs": {"search_results": {"state": "nonempty"}}, "sample_name": "装修规则初筛:结构化搜索"}) content.update(validation_id=validation["id"], evidence=validation.get("evidence") or []) for key in ("execution_profile", "execution_contract"): if key in validation.get("environment", {}): content[key] = deepcopy(validation["environment"][key]) if validation["status"] != "passed": content.update(status="search_failed", errors=["结构化搜索未通过验证,请查看关联验证和页面证据"], error_codes=safe_error_codes(validation.get("error_codes")) or ["SEARCH_VALIDATION_FAILED"]) else: output = validation["actual_outputs"]["search_results"] if origin(output["source_url"]) != origin(candidate["manifest"]["entry_url"]): content.update(status="search_failed", errors=["搜索结果来源与当前候选站点不一致"], error_codes=["DISCOVERY_SOURCE_MISMATCH"]) else: content["results"], content["selected"] = select_results(output["items"], candidate["manifest"]["entry_url"], values["max_selected"]) content["status"] = "completed" except HTTPException as error: detail = error.detail if isinstance(error.detail, dict) else {} content.update(errors=["搜索未执行完成,请核对学习会话和候选静态校验"], error_codes=safe_error_codes([detail.get("code")]) or ["SEARCH_NOT_READY"]) run_id, timestamp = identifier("discovery"), now() self.store.execute("INSERT INTO discovery_runs(id,owner_id,candidate_id,validation_id,data_json,created_at) VALUES($1,$2,$3,$4,$5,$6)", (run_id, owner_id, candidate["id"], content["validation_id"], encoded(content), timestamp)) return self.get(run_id, owner_id)