File size: 6,209 Bytes
275d495
 
 
 
 
 
 
 
 
 
 
 
 
 
15272d6
 
 
c121a68
15272d6
 
c121a68
15272d6
 
 
 
 
 
 
 
275d495
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15272d6
275d495
 
 
15272d6
275d495
 
 
 
 
 
 
15272d6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275d495
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
"""Non-LLM policy gates for financial-agent execution.

These rules are intentionally evaluated in code at both the API entry point
and immediately before tool dispatch. Prompts may explain the policy, but
they are not an enforcement boundary.
"""
from __future__ import annotations

import re
from dataclasses import dataclass
from datetime import date
from typing import Any

FINANCIAL_ADVICE_TERMS = ("买入", "卖出", "推荐", "建仓", "加仓", "减仓", "止损", "目标价", "值得买", "该买吗")
# Match requests to perform an action, rather than financial vocabulary.
# In particular, “交易日/交易量/交易策略” are ordinary research concepts.
_ACTION = r"(?:下单|挂单|撤单|转账|提现|代买|代卖|买入|卖出|建仓|加仓|减仓|清仓|申购|赎回|交易)"
_MODIFIER = r"(?:请|帮我|替我|代我|为我|给我|立即|立刻|马上|现在|直接|自动|实际|执行|进行|发起|提交|确认|启动|取消|操作|一键|一下|一笔|一次|这笔|此笔|\s){0,8}"
_EXECUTION_PATTERNS = (
    re.compile(r"(?:帮我|帮忙|替我|代我|为我|给我|请你|让你|麻烦你|请|立即|立刻|马上|现在|直接|自动|实际|执行|进行|发起|提交|确认|启动|取消)" + _MODIFIER + _ACTION),
    re.compile(r"(?:^|[,,。;;!?!?\n]|然后|接着|并且|并)\s*" + _MODIFIER + _ACTION + r"(?=\s*(?:买入|卖出|\d|[一二三四五六七八九十百千万]))"),
    re.compile(r"(?:^|[,,。;;!?!?\n])\s*" + _MODIFIER + r"(?:把|将)[^,,。;;!?!?\n]{1,40}?(?:转给|转到|转入|转出)"),
)
_RESEARCH_SUFFIX = re.compile(
    r"^(?:的)?(?:日|量|额|所|数据|记录|明细|统计|策略|模型|规则|制度|时间|时段|"
    r"成本|费用|手续费|风险|建议|时机|信号|逻辑|理由|流程|步骤|原理|机制|失败|原因|条件|限制|示例|"
    r"前|是什么|是什么意思|怎么|如何|是否|能否|吗|么|呢|后多久到账)"
)
_NEGATED_REQUEST = re.compile(r"(?:不要|不必|无需|不用|别|禁止|不是(?:让你)?)\s*$")
LONG_WINDOW_TERMS = {
    "一年": 365,
    "半年": 180,
    "三个月": 90,
    "2个月": 60,
    "两个月": 60,
    "一个月": 31,
    "1个月": 31,
}


@dataclass(frozen=True)
class PolicyDecision:
    allowed: bool
    code: str | None = None
    message: str | None = None
    notices: tuple[str, ...] = ()


def assess_user_query(query: str) -> PolicyDecision:
    text = query.lower()
    if _requests_execution(text):
        return PolicyDecision(
            allowed=False,
            code="EXECUTION_NOT_SUPPORTED",
            message="我可以帮你查询和分析相关数据,但不能替你实际下单、转账或操作账户。",
        )
    notices: list[str] = []
    if any(term in text for term in FINANCIAL_ADVICE_TERMS):
        notices.append("内容仅供研究参考,不构成个性化投资建议或交易指令;请结合自身风险承受能力独立判断。")
    return PolicyDecision(allowed=True, notices=tuple(notices))


def _requests_execution(text: str) -> bool:
    """Conservatively recognize explicit execution requests in each clause.

    Read-only questions and negated requests are allowed. Negation is local
    to a matched request: “不要解释,直接下单” still contains an action.
    Actual capabilities remain restricted by the read-only tool registry.
    """
    for pattern in _EXECUTION_PATTERNS:
        for match in pattern.finditer(text):
            if _NEGATED_REQUEST.search(text[:match.start()]):
                continue
            if _RESEARCH_SUFFIX.match(text[match.end():].lstrip(" \t\n”\"’'」』")):
                continue
            return True
    return False


def assess_tool_call(name: str, args: dict[str, Any]) -> PolicyDecision:
    """Enforce the 30-day financial-query limit on continuous indicator windows."""
    if name != "financial-query":
        return PolicyDecision(allowed=True)
    query = str(args.get("query", ""))
    if "资金面" in query:
        return PolicyDecision(
            allowed=False,
            code="FINANCIAL_QUERY_FORBIDDEN_TERM",
            message=(
                "financial-query 的输入不得包含“资金面”。"
                "请改用用户明确要求的具体指标,例如主力资金净流入、北向资金或融资融券。"
            ),
        )
    days = _continuous_window_days(query)
    if days is not None and days > 30:
        return PolicyDecision(
            allowed=False,
            code="FINANCIAL_QUERY_WINDOW_EXCEEDED",
            message=(
                "合规限制:financial-query 不允许查询连续超过 30 天的金融指标时间窗。"
                "请拆分为不超过 30 天的独立区间,或改用公告、研报、新闻等适用数据源。"
            ),
        )
    return PolicyDecision(allowed=True)


def _continuous_window_days(query: str) -> int | None:
    """Return an explicit continuous-window length found in a natural query.

    We only block explicit time windows. Terms such as “2024 年财报” are a
    reporting period, not a day-by-day continuous indicator request.
    """
    normalized = query.replace(" ", "")
    numeric_months = re.search(r"(?:近|过去|连续|最近)?(\d{1,2})个月", normalized)
    if numeric_months:
        return int(numeric_months.group(1)) * 30
    for term, days in LONG_WINDOW_TERMS.items():
        if term in normalized and any(marker in normalized for marker in ("近", "过去", "连续", "日", "每天", "日度", "走势", "行情")):
            return days
    match = re.search(r"(?:近|过去|连续|最近)?(\d{1,4})\s*天", normalized)
    if match:
        return int(match.group(1))
    # ISO-like explicit date range, inclusive. Do not guess other ambiguous
    # natural language date formats.
    match = re.search(r"(20\d{2}-\d{1,2}-\d{1,2})\s*(?:至|到|~|-|-)\s*(20\d{2}-\d{1,2}-\d{1,2})", normalized)
    if match:
        try:
            start = date.fromisoformat(match.group(1))
            end = date.fromisoformat(match.group(2))
            return abs((end - start).days) + 1
        except ValueError:
            return None
    return None