This commit is contained in:
@@ -553,13 +553,14 @@ def local_model(skill, values, evidence, system_prompt=None):
|
||||
endpoint=os.getenv("XINGBANG_LOCAL_MODEL_URL", "")
|
||||
if not endpoint: raise ValueError("未配置 XINGBANG_LOCAL_MODEL_URL")
|
||||
url=urlparse(endpoint)
|
||||
allowed=url.hostname=="localhost"
|
||||
official_deepseek=url.scheme=="https" and url.hostname=="api.deepseek.com" and url.port in (None,443)
|
||||
allowed=url.hostname=="localhost" or official_deepseek
|
||||
try:
|
||||
address=ipaddress.ip_address(url.hostname or "")
|
||||
allowed=address.is_loopback or any(address in ipaddress.ip_network(net) for net in ("10.0.0.0/8","172.16.0.0/12","192.168.0.0/16") if address.version==4)
|
||||
except ValueError: pass
|
||||
if url.scheme not in ("http","https") or not allowed or url.username or url.password:
|
||||
raise ValueError("模型地址必须使用 localhost 或内网 IP,禁止外部模型地址")
|
||||
if url.scheme not in ("http","https") or not allowed or url.username or url.password or url.query or url.fragment:
|
||||
raise ValueError("模型地址必须使用 localhost、内网 IP 或 DeepSeek 官方 HTTPS 地址")
|
||||
from graph.risk_task import invoke_risk_task
|
||||
return invoke_risk_task(skill, values, evidence, endpoint, system_prompt=system_prompt)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from typing import Literal
|
||||
import httpx
|
||||
from langchain_core.tools import tool
|
||||
from langchain_openai import ChatOpenAI
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from graph.agent import agent_manager
|
||||
|
||||
@@ -31,6 +31,15 @@ class ModelResult(BaseModel):
|
||||
requirement_response: str = Field(min_length=1, max_length=5000)
|
||||
experience_proposal: "ExperienceProposal | None" = None
|
||||
|
||||
@field_validator("requirement_response", mode="before")
|
||||
@classmethod
|
||||
def normalize_requirement_response(cls, value):
|
||||
if isinstance(value, dict):
|
||||
return ";".join(f"{key}:{item}" for key, item in value.items())
|
||||
if isinstance(value, list):
|
||||
return ";".join(str(item) for item in value)
|
||||
return value
|
||||
|
||||
|
||||
class ExperienceProposal(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
@@ -49,16 +58,21 @@ ModelResult.model_rebuild()
|
||||
def configuration():
|
||||
endpoint = os.getenv("XINGBANG_LOCAL_MODEL_URL", "")
|
||||
if not endpoint:
|
||||
return {"available": False, "reason": "未配置内网模型,语义核查未执行"}
|
||||
return {"available": False, "reason": "未配置受信模型,语义核查未执行"}
|
||||
url = urlparse(endpoint)
|
||||
allowed = url.hostname == "localhost"
|
||||
official_deepseek = (
|
||||
url.scheme == "https"
|
||||
and url.hostname == "api.deepseek.com"
|
||||
and url.port in (None, 443)
|
||||
)
|
||||
allowed = url.hostname == "localhost" or official_deepseek
|
||||
try:
|
||||
address = ipaddress.ip_address(url.hostname or "")
|
||||
allowed = address.is_loopback or any(address in ipaddress.ip_network(net) for net in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16") if address.version == 4)
|
||||
except ValueError:
|
||||
pass
|
||||
if url.scheme not in ("http", "https") or not allowed or url.username or url.password or url.query or url.fragment:
|
||||
raise ValueError("模型须为已批准的内网地址,不允许凭据或参数嵌入 URL")
|
||||
raise ValueError("模型须为已批准的内网地址或 DeepSeek 官方 HTTPS 地址,不允许凭据或参数嵌入 URL")
|
||||
return {"available": True, "endpoint": endpoint, "name": os.getenv("XINGBANG_LOCAL_MODEL_NAME", "local-model")}
|
||||
|
||||
|
||||
@@ -69,7 +83,8 @@ PROMPT = """你是兴邦项目核查助手,必须通过受限工具读取本
|
||||
输出一个JSON对象,只含 findings 和 requirement_response。findings 每项字段为 code(稳定英文码)、title、result
|
||||
(pass/issue/unknown/not_applicable)、evidence_ids(真实证据ID数组)、fields(标准字段数组)、suggested_action。
|
||||
没有依据不得判定pass/issue。缺失资料标unknown,证据只能引用冻结快照。不得引用其他项目经验中的客户信息。
|
||||
requirement_response逐项说明是否完成用户要求,无法验证要明确说明。不要Markdown围栏。"""
|
||||
requirement_response必须是一个普通字符串,不得输出对象或数组;逐项说明是否完成用户要求,无法验证要明确说明。
|
||||
示例顶层结构:{"findings": [], "requirement_response": "已读取授权事实并完成核查。"}。不要Markdown围栏。"""
|
||||
|
||||
|
||||
def invoke(skill, snapshot, checks, rules, requirement, experiences, model_config, budget, cancelled=lambda: False, feedback=None):
|
||||
|
||||
@@ -602,10 +602,17 @@ class ProjectAssistantService(DocumentsMixin, ExperiencesMixin):
|
||||
if current["data_domain"] != "demo":
|
||||
raise ValueError("禁止重置正式项目")
|
||||
records = [dict(r) for r in db.execute("SELECT * FROM assistant_objects WHERE project_id=? OR (kind='project' AND id=?)", (item["id"], item["id"]))]
|
||||
put(db, "reset_archive", {"id": uid("reset"), "source_project_id": item["id"], "records": records, "actor": actor, "created_at": now()}, immutable=True)
|
||||
events = [dict(r) for r in db.execute("SELECT * FROM assistant_events WHERE project_id=? ORDER BY seq", (item["id"],))]
|
||||
time_events = [dict(r) for r in db.execute("SELECT * FROM business_time_events WHERE project_id=? ORDER BY started_at", (item["id"],))]
|
||||
put(db, "reset_archive", {"id": uid("reset"), "source_project_id": item["id"], "records": records,
|
||||
"events": events, "time_events": time_events, "actor": actor, "created_at": now()}, immutable=True)
|
||||
db.execute("DELETE FROM assistant_jobs WHERE project_id=?", (item["id"],))
|
||||
db.execute("DELETE FROM assistant_idempotency WHERE object_id IN (SELECT id FROM assistant_objects WHERE project_id=?)", (item["id"],))
|
||||
db.execute("DELETE FROM assistant_objects WHERE project_id=? OR (kind='project' AND id=?)", (item["id"], item["id"]))
|
||||
db.execute("DELETE FROM assistant_events WHERE project_id=?", (item["id"],))
|
||||
db.execute("DELETE FROM business_time_events WHERE project_id=?", (item["id"],))
|
||||
self._seed_project(db, item, "demo")
|
||||
self._seed_timeline(db, item)
|
||||
event(db, item["id"], "demo.reset", actor, {"recoverable": True})
|
||||
return {"status": "reset", "data_domain": "demo", "projects": len(fixtures["projects"]), "recoverable": True}
|
||||
return {"status": "reset", "data_domain": "demo", "projects": len(fixtures["projects"]),
|
||||
"recoverable": True, "history_reset": True}
|
||||
|
||||
@@ -9,6 +9,7 @@ import test_project_assistant as fixtures
|
||||
from services.assistant_store import get, put, listing
|
||||
from services.component_registry import draft, release, ordered, normalize
|
||||
from services.component_evaluation import evaluate
|
||||
from services.assistant_model import ModelResult, configuration
|
||||
|
||||
|
||||
class StructuredToolModel(BaseChatModel):
|
||||
@@ -44,6 +45,25 @@ class StructuredToolModel(BaseChatModel):
|
||||
|
||||
class ComponentTests(fixtures.ProjectAssistantTests):
|
||||
# Reuse only the fixture helpers, not inherited test execution (filtered below).
|
||||
def test_model_endpoint_policy_allows_only_private_or_official_deepseek(self):
|
||||
with patch.dict("os.environ", {"XINGBANG_LOCAL_MODEL_URL": "https://api.deepseek.com", "XINGBANG_LOCAL_MODEL_NAME": "deepseek-chat"}):
|
||||
config = configuration()
|
||||
self.assertTrue(config["available"])
|
||||
self.assertEqual(config["name"], "deepseek-chat")
|
||||
with patch.dict("os.environ", {"XINGBANG_LOCAL_MODEL_URL": "http://api.deepseek.com"}):
|
||||
with self.assertRaises(ValueError):
|
||||
configuration()
|
||||
|
||||
def test_requirement_response_object_is_normalized_to_readable_text(self):
|
||||
result = ModelResult.model_validate({
|
||||
"findings": [],
|
||||
"requirement_response": {"数据读取": "完成", "待核实": "现金流缺失"},
|
||||
})
|
||||
self.assertEqual(result.requirement_response, "数据读取:完成;待核实:现金流缺失")
|
||||
with patch.dict("os.environ", {"XINGBANG_LOCAL_MODEL_URL": "https://example.com/v1"}):
|
||||
with self.assertRaises(ValueError):
|
||||
configuration()
|
||||
|
||||
def test_real_agent_manager_and_invalid_evidence(self):
|
||||
with patch.dict("os.environ", {"XINGBANG_LOCAL_MODEL_URL": "http://127.0.0.1:11434/v1"}), patch("services.assistant_model.ChatOpenAI", return_value=StructuredToolModel()):
|
||||
a = self.run_project("DEMO-A", "跳过现金流检查")
|
||||
|
||||
@@ -211,10 +211,15 @@ class ProjectAssistantTests(unittest.TestCase):
|
||||
self.assertEqual(self.post("/demo/reset").status_code, 422)
|
||||
result = self.post("/demo/reset", {"confirmation": "RESET-DEMO-A-B-C"})
|
||||
self.assertEqual(result.status_code, 200, result.text)
|
||||
self.assertTrue(result.json()["history_reset"])
|
||||
self.assertEqual(self.project()["revision"], 1)
|
||||
with self.service.connect() as db:
|
||||
self.assertEqual(len(listing(db, "reset_archive")), 3)
|
||||
self.assertTrue(listing(db, "component_version"))
|
||||
project = self.project("DEMO-B")
|
||||
self.assertEqual(project["stage"], "补件中")
|
||||
self.assertEqual(len(project["timeline"]), 3)
|
||||
self.assertEqual([item["action"] for item in project["events"]], ["demo.reset"])
|
||||
|
||||
def test_mock_and_readonly_sqlite_adapter_agree(self):
|
||||
import sqlite3
|
||||
|
||||
@@ -67,6 +67,11 @@ class RiskAgentTests(unittest.TestCase):
|
||||
def test_model_never_calls_external_url(self):
|
||||
with patch.dict("os.environ",{"XINGBANG_LOCAL_MODEL_URL":"https://example.com/v1"}):
|
||||
with self.assertRaises(ValueError):engine.local_model({}, {}, {})
|
||||
def test_official_deepseek_model_url_is_allowed(self):
|
||||
with patch.dict("os.environ",{"XINGBANG_LOCAL_MODEL_URL":"https://api.deepseek.com"}), patch("graph.risk_task.invoke_risk_task",return_value={"text":"ok"}) as invoke:
|
||||
result=engine.local_model({}, {}, {})
|
||||
self.assertEqual(result,{"text":"ok"})
|
||||
self.assertEqual(invoke.call_args.args[3],"https://api.deepseek.com")
|
||||
def test_crud_custom_skill(self):
|
||||
response=self.client.post(self.base+"/skills",json={"name":"新检查","data":{"engine":"quality","inputs":["*"],"output":"quality"}})
|
||||
self.assertEqual(response.status_code,200,response.text)
|
||||
|
||||
Reference in New Issue
Block a user