Files

136 lines
5.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: UTF-8 -*-
"""AI 意图理解客户端。
对接任意 OpenAI 兼容接口(可自定义 base_url),将用户的自然语言输入解析为
结构化的搜索意图,供机器人选择合适的爬虫 / API 进行资源搜索。
"""
import json
import logging
import re
import requests
LOG = logging.getLogger(__name__)
SYSTEM_PROMPT = """你是一个 Telegram 资源搜索机器人的「意图理解」模块。用户会用自然语言描述想要的资源,可能包括:
- AV 番号(如 ABP-123、FC2-PPV-12345
- 演员名字
- 电影 / 电视剧名称
- 一段剧情、台词或影片描述
- 电影类型 / 题材(如「悬疑电影」「科幻片」)
请解析用户输入,并只输出一个 JSON 对象(不要输出任何其他文字),字段如下:
{
"intent": "av_id | actor | genre | keyword | plot | general",
"target": "解析后的搜索目标(番号 / 演员名 / 关键词 / 推测出的片名)",
"source": "javdb | javbus | sukebei | dmm | apibay | auto",
"explain": "用一句话说明你理解到的搜索目标"
}
规则:
- intent=av_id:输入是明确的番号;target 保持番号原样(英文字母大写、连字符保留)
- intent=actor:仅当输入是明确的日本 AV 演员 / 演员名(例如「波多野结衣」「桥本有菜」「三上悠亜」);target 必须是最可能的正式演员名,不要直接原样照抄用户输入;如果用户把演员名打错、同音字、错别字、简写、日文假名近似、中文音译偏差,优先纠正成真正的日本 AV 演员名;如果用户说的是普通成人明星、非 AV 演员、或无法确定是日本 AV 演员,则不要使用 actor,改用 keyword / general
- intent=genre:输入是电影类型 / 题材;target 为该类型关键词(如「悬疑」)
- intent=plot:输入是剧情 / 台词 / 描述;target 为推测出的最可能片名(不确定也给一个最接近的)
- intent=keyword:输入是电影 / 电视剧名称或其他明确关键词;target 为精简后的关键词
- intent=general:无法明确分类时使用;target 为原始输入的精简版
- source 表示最合适的数据源:AV 相关用 javdb/javbus/sukebei/dmm,电影 / 电视剧 / 通用关键词用 apibay,拿不准用 auto
- target 尽量精简,去掉语气词和多余描述
- 特别注意:演员搜索是“纠错 + 归一化”任务,不要因为用户拼错字或音近字就保留错误名字;例如“波多野结衣”可能写成“波多野结衣”“娑多野结衣”,应修正到正确正式名;“三上悠亚”可能写成“三上悠亜/三上悠亚”都应统一成正式名
"""
class AiClient:
def __init__(
self,
base_url: str = "",
api_key: str = "",
model: str = "gpt-4o-mini",
proxy: str = "",
timeout: int = 60,
):
self.base_url = (base_url or "").rstrip("/")
self.api_key = api_key or ""
self.model = model or "gpt-4o-mini"
self.proxy = proxy or ""
self.proxies = {"http": self.proxy, "https": self.proxy} if self.proxy else None
self.timeout = timeout
@property
def enabled(self):
return bool(self.base_url and self.api_key)
def chat(self, messages, temperature=0.2, max_tokens=800):
if not self.enabled:
return None
url = f"{self.base_url}/chat/completions"
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
payload = {
"model": self.model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
try:
resp = requests.post(
url,
headers=headers,
json=payload,
proxies=self.proxies,
timeout=self.timeout,
)
except Exception as e:
LOG.error(f"AI request failed: {e}")
return None
if resp.status_code != 200:
LOG.error(
f"AI request failed with status {resp.status_code}: {resp.text[:300]}"
)
return None
try:
data = resp.json()
return data["choices"][0]["message"]["content"]
except Exception as e:
LOG.error(f"AI response parse failed: {e}")
return None
def understand(self, text: str):
"""将自然语言输入解析为搜索意图 dict,失败返回 None。"""
if not self.enabled:
return None
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": text},
]
content = self.chat(messages)
if not content:
return None
LOG.info(f"AI 原始返回: {content}")
intent = self._parse_json(content)
LOG.info(f"AI 解析意图: {intent}")
return intent
@staticmethod
def _parse_json(content: str):
content = (content or "").strip()
content = re.sub(r"^```(?:json)?\s*", "", content)
content = re.sub(r"\s*```$", "", content)
try:
return json.loads(content)
except Exception:
pass
start = content.find("{")
end = content.rfind("}")
if start == -1 or end == -1 or end <= start:
LOG.error(f"AI returned non-JSON content: {content[:300]}")
return None
try:
return json.loads(content[start : end + 1])
except Exception as e:
LOG.error(f"AI JSON parse failed: {e}, content: {content[:300]}")
return None