Skip to content

Commit 3d1ef78

Browse files
committed
fix: harden intelligence source ingestion
1 parent 954a330 commit 3d1ef78

9 files changed

Lines changed: 271 additions & 8 deletions

api/v1/endpoints/intelligence.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
IntelligenceSourceCreateRequest,
1616
IntelligenceSourceItem,
1717
IntelligenceSourceListResponse,
18+
IntelligenceSourceTemplateCreateRequest,
19+
IntelligenceSourceTemplateListResponse,
1820
IntelligenceSourceTestResponse,
1921
)
2022
from src.services.intelligence_service import IntelligenceService, IntelligenceServiceError
@@ -66,6 +68,39 @@ def list_sources(
6668
raise _internal_error("List intelligence sources failed", exc)
6769

6870

71+
@router.get("/sources/templates", response_model=IntelligenceSourceTemplateListResponse, responses={500: {"model": ErrorResponse}}, summary="List built-in intelligence source templates")
72+
def list_source_templates(
73+
source_type: Optional[str] = Query(None),
74+
market: Optional[str] = Query(None),
75+
) -> IntelligenceSourceTemplateListResponse:
76+
try:
77+
return IntelligenceSourceTemplateListResponse(**IntelligenceService().list_source_templates(
78+
source_type=source_type,
79+
market=market,
80+
))
81+
except Exception as exc:
82+
raise _internal_error("List intelligence source templates failed", exc)
83+
84+
85+
@router.post("/sources/templates/{template_id}", response_model=IntelligenceSourceItem, responses={400: {"model": ErrorResponse}, 404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}}, summary="Create intelligence source from a built-in template")
86+
def create_source_from_template(
87+
template_id: str,
88+
request: IntelligenceSourceTemplateCreateRequest = IntelligenceSourceTemplateCreateRequest(),
89+
) -> IntelligenceSourceItem:
90+
try:
91+
return IntelligenceSourceItem(**IntelligenceService().create_source_from_template(
92+
template_id,
93+
request.model_dump(exclude_none=True),
94+
))
95+
except IntelligenceServiceError as exc:
96+
message = str(exc)
97+
if "template not found" in message.lower():
98+
raise _not_found(message)
99+
raise _bad_request(exc)
100+
except Exception as exc:
101+
raise _internal_error("Create intelligence source from template failed", exc)
102+
103+
69104
@router.post("/sources/test", response_model=IntelligenceSourceTestResponse, responses={400: {"model": ErrorResponse}, 500: {"model": ErrorResponse}}, summary="Dry-run an intelligence source payload")
70105
def test_source_payload(request: IntelligenceSourceCreateRequest) -> IntelligenceSourceTestResponse:
71106
try:

api/v1/schemas/intelligence.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@ class IntelligenceSourceCreateRequest(BaseModel):
2323
description: Optional[str] = None
2424

2525

26+
class IntelligenceSourceTemplateCreateRequest(BaseModel):
27+
name: Optional[str] = Field(None, min_length=1, max_length=100)
28+
enabled: Optional[bool] = None
29+
scope_type: Optional[ScopeTypeValue] = None
30+
scope_value: Optional[str] = Field(None, max_length=64)
31+
market: Optional[MarketValue] = None
32+
description: Optional[str] = None
33+
34+
2635
class IntelligenceSourceItem(BaseModel):
2736
id: int
2837
name: str
@@ -40,13 +49,29 @@ class IntelligenceSourceItem(BaseModel):
4049
updated_at: Optional[str] = None
4150

4251

52+
class IntelligenceSourceTemplateItem(BaseModel):
53+
template_id: str
54+
name: str
55+
source_type: str
56+
url: str
57+
scope_type: str
58+
scope_value: Optional[str] = None
59+
market: str
60+
description: Optional[str] = None
61+
62+
4363
class IntelligenceSourceListResponse(BaseModel):
4464
items: List[IntelligenceSourceItem] = Field(default_factory=list)
4565
total: int
4666
page: int
4767
page_size: int
4868

4969

70+
class IntelligenceSourceTemplateListResponse(BaseModel):
71+
items: List[IntelligenceSourceTemplateItem] = Field(default_factory=list)
72+
total: int
73+
74+
5075
class IntelligenceItem(BaseModel):
5176
id: int
5277
source_id: Optional[int] = None

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
3939
- [文档] 补充 AlphaSift 迁移与回退边界:明确 `ALPHASIFT_INSTALL_SPEC` 显式覆盖语义、`requirements.txt + DEFAULT_ALPHASIFT_INSTALL_SPEC` 与运行时兼容边界、以及回滚路径(关闭功能/完整 revert)说明,覆盖旧 pin 用户升级行为。
4040
- [新功能] #1707 新增合规 RSS/Atom 资讯源配置、拉取、去重、入库、查询、retention 与基础安全校验 API,作为个股/市场资讯情报池基线。
4141
- [改进] #1707 个股分析、Agent 分析和大盘复盘会 fail-open 读取本地资讯/情报池,并把来源链接作为新闻上下文和 evidence 输入。
42+
- [改进] #1707 补齐内置 RSS/Atom 资讯源模板后端入口,修复本地资讯拉取的 requests 参数兼容、请求阶段 DNS 校验、共享地址段拒绝、坏条目跳过与港股短代码匹配。
4243
- [文档] #1707 阐明情报池接入仅追加本地资讯消费,不改模型名/provider/base URL/默认模型策略/回退策略/保存前清理逻辑或运行时配置迁移;结构化风险提示若出现为关键词误报;回滚可采用 `revert` 本 PR 或停用/移除本地资讯接入入口与数据。
4344

4445
- [新功能] 个股分析历史成功保存后会从最终报告 best-effort 提取 `DecisionSignal` 决策信号,复用现有信号去重、计划质量计算和脱敏契约。

docs/intelligence-sources.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ Issue #1707 的首版能力聚焦“合规资讯源采集、本地沉淀、可
55
## 能力范围
66

77
- 支持配置 RSS / Atom HTTP(S) 资讯源。
8+
- 支持查询内置 RSS/Atom 模板,并可从模板创建可测试、可启停的资讯源。
89
- 保存资讯源配置、启用状态、作用域和最近一次拉取状态。
910
- 拉取条目落库到 `intelligence_items`,保存标题、摘要、URL、来源、发布时间、拉取时间、市场与作用域。
1011
- 按 URL 去重;无 URL 条目使用 `no-url:intel:<hash>` 兜底键。
@@ -18,7 +19,9 @@ Issue #1707 的首版能力聚焦“合规资讯源采集、本地沉淀、可
1819

1920
- 只允许绝对 `http` / `https` URL;
2021
- 禁止 URL 中携带 username/password;
21-
- 禁止 `localhost``.local`、回环地址、内网地址、链路本地地址、保留地址和组播地址;
22+
- 禁止 `localhost``.local`、回环地址、内网地址、链路本地地址、保留地址、共享地址段和组播地址;
23+
- 解析与拉取阶段显式禁用环境代理(如 `HTTP_PROXY``HTTPS_PROXY``ALL_PROXY`),避免通过环境代理绕过校验边界;
24+
- 实际连接阶段会再次校验目标主机 DNS 解析结果,避免校验后解析漂移到受限地址;
2225
- 重定向后的最终 URL 也会再次校验;
2326
- 错误消息会脱敏常见 `token` / `key` / `secret` 查询参数。
2427

@@ -38,6 +41,8 @@ NEWS_INTEL_MAX_ITEMS_PER_SOURCE=50
3841

3942
- `POST /sources`:创建资讯源。
4043
- `GET /sources`:查询资讯源。
44+
- `GET /sources/templates?market=hk`:查询内置资讯源模板。
45+
- `POST /sources/templates/{template_id}`:从内置模板创建资讯源,可覆盖名称、启用状态、作用域和说明。
4146
- `POST /sources/test`:测试 payload,不落库。
4247
- `POST /sources/{source_id}/fetch?dry_run=false`:拉取单个源。
4348
- `POST /sources/fetch-enabled`:fail-open 拉取全部启用源。
@@ -52,7 +57,7 @@ NEWS_INTEL_MAX_ITEMS_PER_SOURCE=50
5257
- 大盘复盘会把同市场 `market` 级资讯合并到市场新闻列表,Prompt、结构化 payload 和报告 news 字段都能看到来源链接。
5358
- 本次能力仅新增本地资讯消费路径,不改模型名、provider/base URL、默认模型策略、回退策略、`save_context_snapshot` 前清理逻辑或运行时配置语义;兼容现有部署配置,回滚方式为清退本地资讯接入入口或移除本地资讯源配置/数据。
5459

55-
后续 PR 可以继续完善报告 evidence 展示和 Web 设置/报告查看入口。
60+
后续 PR 可以继续完善 NewsNow HTTP provider、报告 evidence 展示和 Web 设置/报告查看入口。
5661

5762
## 兼容性与回滚说明(Issue #1707
5863

src/core/pipeline.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ def add_case_variants(value: str) -> None:
123123
trimmed_digits = digits.lstrip("0") or digits
124124
add_case_variants(normalized_upper)
125125
add_case_variants(digits)
126+
add_case_variants(trimmed_digits)
127+
add_case_variants(f"HK{trimmed_digits}")
126128
add_case_variants(f"{trimmed_digits}.HK")
127129
add_case_variants(f"{digits}.HK")
128130
return values

src/services/intelligence_service.py

Lines changed: 118 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import logging
1010
import re
1111
import socket
12+
import threading
1213
from dataclasses import dataclass
1314
from datetime import datetime, timezone
1415
from email.utils import parsedate_to_datetime
@@ -17,6 +18,7 @@
1718
from xml.etree import ElementTree as ET
1819

1920
import requests
21+
from sqlalchemy.exc import IntegrityError
2022

2123
from src.config import get_config
2224
from src.repositories.intelligence_repo import IntelligenceRepository
@@ -31,6 +33,37 @@
3133
_MAX_FEED_BYTES = 2 * 1024 * 1024
3234
_MAX_FEED_REDIRECTS = 5
3335
_REDIRECT_STATUS_CODES = {301, 302, 303, 307, 308}
36+
_DISABLE_REQUEST_PROXIES = {"http": None, "https": None}
37+
_DNS_GUARD_LOCK = threading.Lock()
38+
_BUILTIN_SOURCE_TEMPLATES = [
39+
{
40+
"template_id": "sec-company-news",
41+
"name": "SEC Latest Filings",
42+
"source_type": "rss",
43+
"url": "https://www.sec.gov/news/pressreleases.rss",
44+
"scope_type": "market",
45+
"market": "us",
46+
"description": "SEC official press release RSS feed for US market evidence.",
47+
},
48+
{
49+
"template_id": "hkex-news",
50+
"name": "HKEX Market News",
51+
"source_type": "rss",
52+
"url": "https://www.hkex.com.hk/Services/RSS-Feeds/News-Releases?sc_lang=en",
53+
"scope_type": "market",
54+
"market": "hk",
55+
"description": "HKEX public news entry for Hong Kong market evidence. Test before enabling.",
56+
},
57+
{
58+
"template_id": "global-marketwatch",
59+
"name": "MarketWatch Top Stories",
60+
"source_type": "rss",
61+
"url": "https://feeds.content.dowjones.io/public/rss/mw_topstories",
62+
"scope_type": "market",
63+
"market": "global",
64+
"description": "Public market news RSS for global market context. Test before enabling.",
65+
},
66+
]
3467

3568

3669
class IntelligenceServiceError(ValueError):
@@ -57,7 +90,10 @@ def __init__(self, repository: Optional[IntelligenceRepository] = None):
5790
def create_source(self, payload: Dict[str, Any]) -> Dict[str, Any]:
5891
fields = self._normalize_source_fields(payload)
5992
self._validate_url(fields["url"])
60-
return self._source_to_dict(self.repo.create_source(fields))
93+
try:
94+
return self._source_to_dict(self.repo.create_source(fields))
95+
except IntegrityError as exc:
96+
raise IntelligenceServiceError(f"intelligence source name already exists: {fields['name']}") from exc
6197

6298
def list_sources(self, **filters: Any) -> Dict[str, Any]:
6399
rows, total = self.repo.list_sources(**filters)
@@ -68,6 +104,29 @@ def list_sources(self, **filters: Any) -> Dict[str, Any]:
68104
"page_size": max(1, min(int(filters.get("page_size") or 50), 100)),
69105
}
70106

107+
def list_source_templates(self, **filters: Any) -> Dict[str, Any]:
108+
market = str(filters.get("market") or "").strip().lower()
109+
source_type = str(filters.get("source_type") or "").strip().lower()
110+
templates = []
111+
for template in _BUILTIN_SOURCE_TEMPLATES:
112+
if market and template["market"] != market:
113+
continue
114+
if source_type and template["source_type"] != source_type:
115+
continue
116+
templates.append(dict(template))
117+
return {"items": templates, "total": len(templates)}
118+
119+
def create_source_from_template(self, template_id: str, overrides: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
120+
selected = next(
121+
(dict(template) for template in _BUILTIN_SOURCE_TEMPLATES if template["template_id"] == template_id),
122+
None,
123+
)
124+
if selected is None:
125+
raise IntelligenceServiceError(f"Intelligence source template not found: {template_id}")
126+
payload = {key: value for key, value in selected.items() if key != "template_id"}
127+
payload.update({key: value for key, value in (overrides or {}).items() if value is not None})
128+
return self.create_source(payload)
129+
71130
def list_items(self, **filters: Any) -> Dict[str, Any]:
72131
rows, total = self.repo.list_items(**filters)
73132
return {
@@ -193,7 +252,7 @@ def _validate_url(self, raw_url: str, *, allow_no_url: bool = False) -> None:
193252
except ValueError:
194253
ip = None
195254
if ip is not None:
196-
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast:
255+
if self._is_blocked_ip(ip):
197256
raise IntelligenceServiceError("source url must not target private or local network addresses")
198257
return
199258
try:
@@ -207,12 +266,23 @@ def _validate_url(self, raw_url: str, *, allow_no_url: bool = False) -> None:
207266
ip = ipaddress.ip_address(info[4][0])
208267
except (IndexError, ValueError):
209268
continue
210-
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast:
269+
if self._is_blocked_ip(ip):
211270
raise IntelligenceServiceError("source url must not target private or local network addresses")
212271
has_public_address = True
213272
if not has_public_address:
214273
raise IntelligenceServiceError(f"source url host DNS resolution failed: {hostname}")
215274

275+
@staticmethod
276+
def _is_blocked_ip(ip: ipaddress._BaseAddress) -> bool:
277+
return (
278+
not ip.is_global
279+
or ip.is_private
280+
or ip.is_loopback
281+
or ip.is_link_local
282+
or ip.is_reserved
283+
or ip.is_multicast
284+
)
285+
216286
def _fetch_feed_entries(self, fields: Dict[str, Any], *, limit: int) -> List[FeedEntry]:
217287
timeout = max(1, min(float(self.config.news_intel_fetch_timeout_sec), 30.0))
218288
headers = {"User-Agent": "daily-stock-analysis-intel/1.0"}
@@ -221,13 +291,12 @@ def _fetch_feed_entries(self, fields: Dict[str, Any], *, limit: int) -> List[Fee
221291
response = None
222292
try:
223293
for _ in range(_MAX_FEED_REDIRECTS + 1):
224-
response = requests.get(
294+
response = self._get_with_validated_dns(
225295
request_url,
226296
timeout=timeout,
227297
headers=headers,
228298
allow_redirects=False,
229299
stream=True,
230-
trust_env=False,
231300
)
232301
status_code = int(getattr(response, "status_code", 200))
233302
if status_code in _REDIRECT_STATUS_CODES:
@@ -269,6 +338,46 @@ def _fetch_feed_entries(self, fields: Dict[str, Any], *, limit: int) -> List[Fee
269338
if response is not None:
270339
response.close()
271340

341+
def _get_with_validated_dns(self, raw_url: str, **kwargs: Any) -> requests.Response:
342+
parsed = urlparse(raw_url)
343+
target_hostname = self._normalize_hostname(parsed.hostname)
344+
original_getaddrinfo = socket.getaddrinfo
345+
346+
def guarded_getaddrinfo(host: Any, port: Any, *args: Any, **inner_kwargs: Any) -> Any:
347+
addrinfos = original_getaddrinfo(host, port, *args, **inner_kwargs)
348+
if self._normalize_hostname(host) == target_hostname:
349+
self._validate_addrinfos(addrinfos)
350+
return addrinfos
351+
352+
with _DNS_GUARD_LOCK:
353+
socket.getaddrinfo = guarded_getaddrinfo
354+
try:
355+
request_kwargs = dict(kwargs)
356+
request_kwargs.setdefault("proxies", _DISABLE_REQUEST_PROXIES)
357+
return requests.get(raw_url, **request_kwargs)
358+
finally:
359+
socket.getaddrinfo = original_getaddrinfo
360+
361+
@staticmethod
362+
def _normalize_hostname(hostname: Any) -> str:
363+
if isinstance(hostname, bytes):
364+
hostname = hostname.decode("ascii", errors="ignore")
365+
normalized = str(hostname or "").strip().lower().rstrip(".")
366+
try:
367+
return normalized.encode("idna").decode("ascii")
368+
except UnicodeError:
369+
return normalized
370+
371+
@staticmethod
372+
def _validate_addrinfos(addr_infos: Any) -> None:
373+
for info in addr_infos or []:
374+
try:
375+
ip = ipaddress.ip_address(info[4][0])
376+
except (IndexError, TypeError, ValueError):
377+
continue
378+
if IntelligenceService._is_blocked_ip(ip):
379+
raise IntelligenceServiceError("source url must not target private or local network addresses")
380+
272381
def _parse_feed(self, content: bytes, *, source_name: str, limit: int) -> List[FeedEntry]:
273382
try:
274383
root = ET.fromstring(content)
@@ -313,7 +422,10 @@ def _build_entry(self, title: str, summary: str, url: str, source_name: str, pub
313422
if not title and not url:
314423
return None
315424
if url:
316-
self._validate_url(url, allow_no_url=True)
425+
try:
426+
self._validate_url(url, allow_no_url=True)
427+
except IntelligenceServiceError:
428+
return None
317429
url_key = url
318430
else:
319431
digest = hashlib.sha256(f"{source_name}|{title}|{published_at}".encode("utf-8")).hexdigest()[:24]

tests/test_intelligence_analysis_integration.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,19 @@ def test_pipeline_loads_hk_symbol_intelligence_with_plain_code_scope(self) -> No
135135
"scope_value": "00700",
136136
"market": "hk",
137137
},
138+
{
139+
"source_name": "hk-trimmed-symbol-feed",
140+
"source_type": "rss",
141+
"title": "Trimmed HK code symbol feed",
142+
"summary": "Trimmed HK source should match canonical analysis code.",
143+
"url": "https://news.example.com/hk-trimmed-code",
144+
"source": "hk-trimmed-symbol-feed",
145+
"published_at": now,
146+
"fetched_at": now,
147+
"scope_type": "symbol",
148+
"scope_value": "HK700",
149+
"market": "hk",
150+
},
138151
])
139152

140153
pipeline = StockAnalysisPipeline.__new__(StockAnalysisPipeline)
@@ -150,6 +163,7 @@ def test_pipeline_loads_hk_symbol_intelligence_with_plain_code_scope(self) -> No
150163
self.assertIsNotNone(context)
151164
assert context is not None
152165
self.assertIn("Plain HK code symbol feed", context)
166+
self.assertIn("Trimmed HK code symbol feed", context)
153167

154168
def test_market_review_merges_persisted_market_intelligence(self) -> None:
155169
analyzer = MarketAnalyzer(config=self.config, region="cn")

0 commit comments

Comments
 (0)