feat: 更新首页信息处理模型,支持内存缓存与过期机制;添加全量目录同步脚本
This commit is contained in:
@@ -263,7 +263,7 @@ AI 模型兼容性由模拟 Chat Completions/Responses HTTP 流与真实 SDK 适
|
|||||||
|
|
||||||
### 首页信息与基础信息处理模型
|
### 首页信息与基础信息处理模型
|
||||||
|
|
||||||
首页提供平台消息、顾问日度排名、已参加比赛与协议、未来活动,独立刷新并保存上次成功快照。消息逐页浏览,活动读取平台分页后按结束时间过滤;日期或排名缺失保持未知。刷新失败保留缓存并提示过期。
|
首页提供平台消息、顾问日度排名、已参加比赛与协议、未来活动,独立刷新并保存上次成功快照。公告与社区资讯仅保留最近一个自然月(按美东时间回推),正文及其 AI 解读只暂存在进程内存中,15 分钟到期或服务重启后清除,不存数据库;消息逐页浏览,活动读取平台分页后按结束时间过滤;日期或排名缺失保持未知。刷新失败保留缓存并提示过期。
|
||||||
|
|
||||||
在大模型服务设置“基础信息处理模型”(沿用 description_model 字段),与 alpha研究使用模型共享连接配置。该模型继续生成 Alpha Description,并用于手动生成首页摘要及比赛规则解读;未配置时仍可查看原始信息,不回退到研究模型。解读保存来源版本及所用模型,来源或连接配置变化后提示待更新,不自动调用模型。比赛解读附原文依据,仅提供信息建议,不执行报名、接受协议或安排任务。
|
在大模型服务设置“基础信息处理模型”(沿用 description_model 字段),与 alpha研究使用模型共享连接配置。该模型继续生成 Alpha Description,并用于手动生成首页摘要及比赛规则解读;未配置时仍可查看原始信息,不回退到研究模型。解读保存来源版本及所用模型,来源或连接配置变化后提示待更新,不自动调用模型。比赛解读附原文依据,仅提供信息建议,不执行报名、接受协议或安排任务。
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Only allowlisted platform paths are fetched. HTML is converted to inert text;
|
|||||||
platform content is never an instruction and never supplies model/tool endpoints.
|
platform content is never an instruction and never supplies model/tool endpoints.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import calendar
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
@@ -131,10 +132,10 @@ def next_offset(data, path, current):
|
|||||||
raise WqError("平台分页信息无法识别", "invalid_response") from None
|
raise WqError("平台分页信息无法识别", "invalid_response") from None
|
||||||
|
|
||||||
|
|
||||||
async def all_pages(client, path):
|
async def all_pages(client, path, limit=None):
|
||||||
output, offset = [], 0
|
output, offset = [], 0
|
||||||
for _ in range(100):
|
for _ in range(100):
|
||||||
data = await client.get(path, params={"offset": offset})
|
data = await client.get(path, params={"offset": offset, **({"limit": limit} if limit else {})})
|
||||||
output.extend(rows(data))
|
output.extend(rows(data))
|
||||||
offset = next_offset(data, path, offset)
|
offset = next_offset(data, path, offset)
|
||||||
if offset is None:
|
if offset is None:
|
||||||
@@ -160,14 +161,17 @@ def competition(item, user):
|
|||||||
async def source(client, module, user, offset, cid):
|
async def source(client, module, user, offset, cid):
|
||||||
if module == "messages":
|
if module == "messages":
|
||||||
path = "/users/self/messages"
|
path = "/users/self/messages"
|
||||||
data = await client.get(path, params={"offset": offset})
|
|
||||||
items = []
|
items = []
|
||||||
for item in rows(data):
|
for item in await all_pages(client, path, limit=100):
|
||||||
|
created = instant(item.get("dateCreated"))
|
||||||
|
if not created or not month_start() <= created <= now():
|
||||||
|
continue
|
||||||
body, links = plain(item.get("description"))
|
body, links = plain(item.get("description"))
|
||||||
items.append(dict(id=text(item.get("id")), title=text(item.get("title")) or "平台消息",
|
items.append(dict(id=text(item.get("id")), title=text(item.get("title")) or "平台消息",
|
||||||
type=text(item.get("type")), date=text(item.get("dateCreated")),
|
type=text(item.get("type")), date=text(item.get("dateCreated")),
|
||||||
description=body, links=links, url="https://platform.worldquantbrain.com/messages/" + ("announcements" if item.get("type") == "ANNOUNCEMENT" else "notifications")))
|
description=body, links=links, url="https://platform.worldquantbrain.com/messages/" + ("announcements" if item.get("type") == "ANNOUNCEMENT" else "notifications")))
|
||||||
return dict(items=items, offset=offset, next_offset=next_offset(data, path, offset), total=number(data.get("count")))
|
items.sort(key=lambda item: instant(item["date"]), reverse=True)
|
||||||
|
return dict(items=items)
|
||||||
if module == "leaderboard":
|
if module == "leaderboard":
|
||||||
data = await client.get("/consultant/boards/leader", params={"user": user})
|
data = await client.get("/consultant/boards/leader", params={"user": user})
|
||||||
matches = [r for r in rows(data) if r.get("user") == user]
|
matches = [r for r in rows(data) if r.get("user") == user]
|
||||||
@@ -227,8 +231,74 @@ def config_version(config):
|
|||||||
return digest([config.description_model, config.base_url, config.api_key_encrypted, config.protocol])
|
return digest([config.description_model, config.base_url, config.api_key_encrypted, config.protocol])
|
||||||
|
|
||||||
|
|
||||||
|
def month_start():
|
||||||
|
"""One calendar month before the Eastern wall time, clamped at month end."""
|
||||||
|
at = now().astimezone(EASTERN)
|
||||||
|
year, month = (at.year, at.month - 1) if at.month > 1 else (at.year - 1, 12)
|
||||||
|
return at.replace(year=year, month=month, day=min(at.day, calendar.monthrange(year, month)[1]))
|
||||||
|
|
||||||
|
|
||||||
|
def message_page(request, user, offset):
|
||||||
|
"""Return a detached page; neither source text nor AI output enters an ORM session."""
|
||||||
|
cached = request.app.state.home_message_cache.get(user)
|
||||||
|
if cached is None:
|
||||||
|
return None
|
||||||
|
if now() - cached.created_at >= timedelta(minutes=15):
|
||||||
|
del request.app.state.home_message_cache[user]
|
||||||
|
return None
|
||||||
|
content = None
|
||||||
|
if cached.content is not None:
|
||||||
|
# Enforce the rolling boundary even on cached reads and generation commits.
|
||||||
|
filtered = [item for item in cached.content["items"]
|
||||||
|
if month_start() <= instant(item["date"]) <= now()]
|
||||||
|
if len(filtered) != len(cached.content["items"]):
|
||||||
|
cached.analyses.clear()
|
||||||
|
cached.content = {"items": filtered}
|
||||||
|
items = cached.content["items"]
|
||||||
|
content = dict(items=items[offset:offset + 10], total=len(items), offset=offset,
|
||||||
|
next_offset=offset + 10 if offset + 10 < len(items) else None)
|
||||||
|
return SimpleNamespace(content=content, version=digest(content), fetched_at=cached.fetched_at,
|
||||||
|
error=cached.error, analysis=cached.analyses.get(offset))
|
||||||
|
|
||||||
|
|
||||||
|
async def read_messages(request, user, offset, refresh):
|
||||||
|
"""Keep only a 15-minute in-process cache; restart/expiry discards interpretations too."""
|
||||||
|
async with lock(request, (user, "messages")):
|
||||||
|
cache = request.app.state.home_message_cache
|
||||||
|
for owner, entry in list(cache.items()):
|
||||||
|
if now() - entry.created_at >= timedelta(minutes=15):
|
||||||
|
del cache[owner]
|
||||||
|
cached = cache.get(user)
|
||||||
|
if cached is not None and not refresh:
|
||||||
|
return message_page(request, user, offset)
|
||||||
|
try:
|
||||||
|
content = await source(request.app.state.runner.client, "messages", user, 0, "")
|
||||||
|
error = None
|
||||||
|
except WqError as exc:
|
||||||
|
content, error = None, str(exc)
|
||||||
|
if await identity(request) != user:
|
||||||
|
raise HTTPException(409, "账户已变化,请重新读取信息")
|
||||||
|
if cached is None:
|
||||||
|
cached = SimpleNamespace(content=None, fetched_at=None, created_at=now(), error=None, analyses={})
|
||||||
|
cache[user] = cached
|
||||||
|
cached.error = error
|
||||||
|
if content is not None:
|
||||||
|
if cached.content != content:
|
||||||
|
cached.analyses.clear()
|
||||||
|
cached.content, cached.fetched_at = content, now()
|
||||||
|
return message_page(request, user, offset)
|
||||||
|
|
||||||
|
|
||||||
|
async def cached_record(request, db, user, module, offset, key):
|
||||||
|
if module == "messages":
|
||||||
|
return message_page(request, user, offset)
|
||||||
|
return await db.get(HomeInformation, (user, key))
|
||||||
|
|
||||||
|
|
||||||
async def read_record(request, user, module, offset, cid, refresh=False):
|
async def read_record(request, user, module, offset, cid, refresh=False):
|
||||||
key = resource_key(module, offset, cid)
|
key = resource_key(module, offset, cid)
|
||||||
|
if module == "messages":
|
||||||
|
return await read_messages(request, user, offset, refresh)
|
||||||
async with lock(request, (user, key)):
|
async with lock(request, (user, key)):
|
||||||
async with request.app.state.sessions() as db:
|
async with request.app.state.sessions() as db:
|
||||||
record = await db.get(HomeInformation, (user, key))
|
record = await db.get(HomeInformation, (user, key))
|
||||||
@@ -303,12 +373,16 @@ async def output(request, user, module, record):
|
|||||||
config = await db.get(AISettings, 1)
|
config = await db.get(AISettings, 1)
|
||||||
_, version = await analysis_sources(request, user, module, record)
|
_, version = await analysis_sources(request, user, module, record)
|
||||||
analysis = record.analysis
|
analysis = record.analysis
|
||||||
|
if module == "messages" and analysis and analysis["source_version"] != version:
|
||||||
|
request.app.state.home_message_cache[user].analyses.pop(record.content["offset"], None)
|
||||||
|
analysis = None
|
||||||
if analysis:
|
if analysis:
|
||||||
analysis = {**analysis, "outdated": analysis["source_version"] != version or analysis["config_version"] != config_version(config)}
|
analysis = {**analysis, "outdated": analysis["source_version"] != version or analysis["config_version"] != config_version(config)}
|
||||||
analysis.pop("config_version", None)
|
analysis.pop("config_version", None)
|
||||||
return dict(content=temporal(record.content, module), fetched_at=record.fetched_at.replace(tzinfo=timezone.utc) if record.fetched_at else None, error=record.error,
|
return dict(content=temporal(record.content, module), fetched_at=record.fetched_at.replace(tzinfo=timezone.utc) if record.fetched_at else None, error=record.error,
|
||||||
stale=bool(record.error) or bool(module == "leaderboard" and record.content and record.content["scope"] != "dailyOsmosisRank:" + now().astimezone(EASTERN).date().isoformat()) or bool(record.fetched_at and now() - record.fetched_at.replace(tzinfo=timezone.utc) > timedelta(hours=24)),
|
stale=bool(record.error) or bool(module == "leaderboard" and record.content and record.content["scope"] != "dailyOsmosisRank:" + now().astimezone(EASTERN).date().isoformat()) or bool(record.fetched_at and now() - record.fetched_at.replace(tzinfo=timezone.utc) > timedelta(hours=24)),
|
||||||
source_version=record.version, analysis=analysis,
|
source_version=record.version, analysis=analysis,
|
||||||
|
ephemeral=module == "messages",
|
||||||
can_generate=bool(config.description_model and config.api_key_encrypted and config.base_url and record.content))
|
can_generate=bool(config.description_model and config.api_key_encrypted and config.base_url and record.content))
|
||||||
|
|
||||||
|
|
||||||
@@ -364,7 +438,7 @@ async def generate(request: Request, module: Module, offset: int = Query(0, ge=0
|
|||||||
raise HTTPException(409, "该模块正在生成解读,请稍候")
|
raise HTTPException(409, "该模块正在生成解读,请稍候")
|
||||||
async with generation_lock:
|
async with generation_lock:
|
||||||
async with request.app.state.sessions() as db:
|
async with request.app.state.sessions() as db:
|
||||||
record = await db.get(HomeInformation, (user, key))
|
record = await cached_record(request, db, user, module, offset, key)
|
||||||
config = await db.get(AISettings, 1)
|
config = await db.get(AISettings, 1)
|
||||||
if not config.description_model or not config.api_key_encrypted or not config.base_url:
|
if not config.description_model or not config.api_key_encrypted or not config.base_url:
|
||||||
raise HTTPException(409, "请先配置基础信息处理模型及共享连接配置")
|
raise HTTPException(409, "请先配置基础信息处理模型及共享连接配置")
|
||||||
@@ -396,13 +470,17 @@ async def generate(request: Request, module: Module, offset: int = Query(0, ge=0
|
|||||||
await ai.authorize(token_hash(request.cookies["wq_session"]))
|
await ai.authorize(token_hash(request.cookies["wq_session"]))
|
||||||
if await identity(request) != user:
|
if await identity(request) != user:
|
||||||
raise HTTPException(409, "账户已变化,请重新生成")
|
raise HTTPException(409, "账户已变化,请重新生成")
|
||||||
async with lock(request, (user, key)):
|
async with lock(request, (user, "messages" if module == "messages" else key)):
|
||||||
async with request.app.state.sessions.begin() as db:
|
async with request.app.state.sessions.begin() as db:
|
||||||
record = await db.get(HomeInformation, (user, key))
|
record = await cached_record(request, db, user, module, offset, key)
|
||||||
current_config = await db.get(AISettings, 1)
|
current_config = await db.get(AISettings, 1)
|
||||||
|
if record is None:
|
||||||
|
raise HTTPException(409, "公告内存缓存已失效,请刷新后重新生成")
|
||||||
_, current_version = await analysis_sources(request, user, module, record)
|
_, current_version = await analysis_sources(request, user, module, record)
|
||||||
if current_version != version or config_version(current_config) != fingerprint:
|
if current_version != version or config_version(current_config) != fingerprint:
|
||||||
raise HTTPException(409, "生成期间来源或模型配置已变化,已保留上次解读,请重新生成")
|
raise HTTPException(409, "生成期间来源或模型配置已变化,已保留上次解读,请重新生成")
|
||||||
record.analysis = dict(**interpretation, source_version=version, config_version=fingerprint,
|
record.analysis = dict(**interpretation, source_version=version, config_version=fingerprint,
|
||||||
model=connection.model, generated_at=now().isoformat())
|
model=connection.model, generated_at=now().isoformat())
|
||||||
|
if module == "messages":
|
||||||
|
request.app.state.home_message_cache[user].analyses[offset] = record.analysis
|
||||||
return await output(request, user, module, record)
|
return await output(request, user, module, record)
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ def create_app(settings=None, wq_client=None, ai_model_factory=None):
|
|||||||
app.state.settings = settings
|
app.state.settings = settings
|
||||||
app.state.ai = ai_runtime
|
app.state.ai = ai_runtime
|
||||||
app.state.home_information_locks = {}
|
app.state.home_information_locks = {}
|
||||||
|
app.state.home_message_cache = {}
|
||||||
app.state.research = research_runtime
|
app.state.research = research_runtime
|
||||||
app.state.mcp = mcp_runtime
|
app.state.mcp = mcp_runtime
|
||||||
login_failures = defaultdict(list)
|
login_failures = defaultdict(list)
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""Remove persisted announcement sources and interpretations; use memory only."""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0017"
|
||||||
|
down_revision = "0016"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
cache = sa.table("home_information", sa.column("resource", sa.String()))
|
||||||
|
op.execute(cache.delete().where(cache.c.resource.like("messages:%")))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# Platform cache is reproducible; deleted text and interpretations are not restored.
|
||||||
|
pass
|
||||||
@@ -35,6 +35,7 @@ async def setup(app, monkeypatch):
|
|||||||
async with app.state.sessions.begin() as db:
|
async with app.state.sessions.begin() as db:
|
||||||
account = await db.get(Account, 1)
|
account = await db.get(Account, 1)
|
||||||
account.connection_status, account.wq_user_id = "connected", "TEST_USER"
|
account.connection_status, account.wq_user_id = "connected", "TEST_USER"
|
||||||
|
monkeypatch.setattr(home, "now", lambda: datetime.fromisoformat("2026-09-12T12:00:00+00:00"))
|
||||||
calls, state = [], {"rank": 25, "fail": set(), "agreement": "Participants must use GLOBAL region. Delay must be 1."}
|
calls, state = [], {"rank": 25, "fail": set(), "agreement": "Participants must use GLOBAL region. Delay must be 1."}
|
||||||
|
|
||||||
async def get(path, params=None, headers=None):
|
async def get(path, params=None, headers=None):
|
||||||
@@ -43,7 +44,7 @@ async def setup(app, monkeypatch):
|
|||||||
raise WqError("资源暂不可用")
|
raise WqError("资源暂不可用")
|
||||||
if path == "/users/self/messages":
|
if path == "/users/self/messages":
|
||||||
offset = params["offset"]
|
offset = params["offset"]
|
||||||
return page([{"id": f"msg{offset}", "title": "Update", "description": "<p>News</p><script>secret()</script><a href='javascript:alert(1)'>bad</a>", "dateCreated": "2026-09-11T12:00:00-04:00"}],
|
return page([{"id": f"msg{i}", "title": "Update", "description": "<p>News</p><script>secret()</script><a href='javascript:alert(1)'>bad</a>", "dateCreated": "2026-09-11T12:00:00-04:00"} for i in (range(10) if offset == 0 else [10])],
|
||||||
"https://api.worldquantbrain.com/users/self/messages?offset=1" if offset == 0 else None)
|
"https://api.worldquantbrain.com/users/self/messages?offset=1" if offset == 0 else None)
|
||||||
if path == "/consultant/boards/leader":
|
if path == "/consultant/boards/leader":
|
||||||
assert params == {"user": "TEST_USER"}
|
assert params == {"user": "TEST_USER"}
|
||||||
@@ -64,12 +65,12 @@ async def setup(app, monkeypatch):
|
|||||||
async def test_cache_pagination_failure_and_account_isolation(app, logged_in, monkeypatch):
|
async def test_cache_pagination_failure_and_account_isolation(app, logged_in, monkeypatch):
|
||||||
calls, state = await setup(app, monkeypatch)
|
calls, state = await setup(app, monkeypatch)
|
||||||
first = (await logged_in.get(PREFIX + "/messages")).json()
|
first = (await logged_in.get(PREFIX + "/messages")).json()
|
||||||
assert first["content"]["next_offset"] == 1
|
assert first["content"]["next_offset"] == 10
|
||||||
assert "secret()" not in str(first) and "javascript:" not in str(first)
|
assert "secret()" not in str(first) and "javascript:" not in str(first)
|
||||||
assert first["analysis"] is None and not first["can_generate"]
|
assert first["analysis"] is None and not first["can_generate"]
|
||||||
assert (await logged_in.get(PREFIX + "/messages")).json() == first and len(calls) == 1
|
assert (await logged_in.get(PREFIX + "/messages")).json() == first and len(calls) == 2
|
||||||
second = (await logged_in.get(PREFIX + "/messages?offset=1")).json()
|
second = (await logged_in.get(PREFIX + "/messages?offset=10")).json()
|
||||||
assert second["content"]["items"][0]["id"] == "msg1"
|
assert second["content"]["items"][0]["id"] == "msg10"
|
||||||
state["fail"].add("/users/self/messages")
|
state["fail"].add("/users/self/messages")
|
||||||
failed = (await logged_in.post(PREFIX + "/messages/refresh")).json()
|
failed = (await logged_in.post(PREFIX + "/messages/refresh")).json()
|
||||||
assert failed["stale"] and failed["error"]
|
assert failed["stale"] and failed["error"]
|
||||||
@@ -193,7 +194,7 @@ async def test_failed_grounding_preserves_prior_analysis(app, logged_in, monkeyp
|
|||||||
async def test_concurrent_first_reads_are_coalesced(app, logged_in, monkeypatch):
|
async def test_concurrent_first_reads_are_coalesced(app, logged_in, monkeypatch):
|
||||||
calls, _ = await setup(app, monkeypatch)
|
calls, _ = await setup(app, monkeypatch)
|
||||||
results = await asyncio.gather(*[logged_in.get(PREFIX + "/messages") for _ in range(4)])
|
results = await asyncio.gather(*[logged_in.get(PREFIX + "/messages") for _ in range(4)])
|
||||||
assert all(r.status_code == 200 for r in results) and len(calls) == 1
|
assert all(r.status_code == 200 for r in results) and len(calls) == 2
|
||||||
|
|
||||||
|
|
||||||
async def test_event_sources_include_competitions_and_current_day(app, logged_in, monkeypatch):
|
async def test_event_sources_include_competitions_and_current_day(app, logged_in, monkeypatch):
|
||||||
@@ -298,3 +299,93 @@ async def test_shared_connection_change_invalidates_ai(app, logged_in, monkeypat
|
|||||||
setattr(await db.get(AISettings, 1), field, value)
|
setattr(await db.get(AISettings, 1), field, value)
|
||||||
assert (await logged_in.get(PREFIX + '/competition' + QUERY)).json()['analysis']['outdated']
|
assert (await logged_in.get(PREFIX + '/competition' + QUERY)).json()['analysis']['outdated']
|
||||||
assert calls == ['basic-model']
|
assert calls == ['basic-model']
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('at,expected', [
|
||||||
|
('2026-03-31T12:00:00-04:00', '2026-02-28T12:00:00-05:00'),
|
||||||
|
('2024-03-31T12:00:00-04:00', '2024-02-29T12:00:00-05:00'),
|
||||||
|
('2026-01-31T12:00:00-05:00', '2025-12-31T12:00:00-05:00'),
|
||||||
|
])
|
||||||
|
def test_recent_month_uses_eastern_calendar_month(monkeypatch, at, expected):
|
||||||
|
monkeypatch.setattr(home, 'now', lambda: datetime.fromisoformat(at))
|
||||||
|
assert home.month_start().isoformat() == expected
|
||||||
|
|
||||||
|
|
||||||
|
async def test_recent_messages_scan_pages_filter_unknown_future_and_boundary(monkeypatch):
|
||||||
|
monkeypatch.setattr(home, 'now', lambda: datetime.fromisoformat('2026-03-31T12:00:00-04:00'))
|
||||||
|
calls = []
|
||||||
|
class Client:
|
||||||
|
async def get(self, path, params):
|
||||||
|
calls.append(params)
|
||||||
|
if params['offset'] == 0:
|
||||||
|
return page([{'id': 'old', 'dateCreated': '2026-02-28T11:59:59-05:00'},
|
||||||
|
{'id': 'unknown'}, {'id': 'future', 'dateCreated': '2026-04-01T00:00:00Z'}],
|
||||||
|
'https://api.worldquantbrain.com/users/self/messages?offset=3&limit=100')
|
||||||
|
return page([{'id': 'boundary', 'dateCreated': '2026-02-28T12:00:00-05:00'},
|
||||||
|
{'id': 'today', 'dateCreated': '2026-03-31T12:00:00-04:00'}])
|
||||||
|
data = await home.source(Client(), 'messages', 'TEST_USER', 0, '')
|
||||||
|
assert [item['id'] for item in data['items']] == ['today', 'boundary']
|
||||||
|
assert calls == [{'offset': 0, 'limit': 100}, {'offset': 3, 'limit': 100}]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_messages_and_ai_never_persist_and_expire_without_auto_generation(app, logged_in, monkeypatch):
|
||||||
|
platform_calls, _ = await setup(app, monkeypatch)
|
||||||
|
await model_setup(app)
|
||||||
|
model_calls = []
|
||||||
|
def respond(messages, info):
|
||||||
|
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, {'items': [], 'suggestions': []})])
|
||||||
|
@asynccontextmanager
|
||||||
|
async def factory(config, settings):
|
||||||
|
model_calls.append(config.model)
|
||||||
|
yield FunctionModel(respond)
|
||||||
|
app.state.ai.model_factory = factory
|
||||||
|
# Legacy rows must never be used; migration will remove them on deployment.
|
||||||
|
async with app.state.sessions.begin() as db:
|
||||||
|
db.add(HomeInformation(user_id='TEST_USER', resource='messages:0', content={'items': [{'title': 'legacy'}]}))
|
||||||
|
first = (await logged_in.get(PREFIX + '/messages')).json()
|
||||||
|
assert first['ephemeral'] and first['content']['total'] == 11
|
||||||
|
generated = await logged_in.post(PREFIX + '/messages/generate')
|
||||||
|
assert generated.status_code == 200, generated.text
|
||||||
|
assert generated.json()['analysis']['model'] == 'basic-model'
|
||||||
|
await logged_in.get(PREFIX + '/messages')
|
||||||
|
await logged_in.get(PREFIX + '/messages?offset=10')
|
||||||
|
assert len(platform_calls) == 2 and model_calls == ['basic-model']
|
||||||
|
async with app.state.sessions() as db:
|
||||||
|
saved = (await db.scalars(sa.select(HomeInformation))).all()
|
||||||
|
assert len(saved) == 1 and saved[0].content == {'items': [{'title': 'legacy'}]}
|
||||||
|
assert saved[0].analysis is None
|
||||||
|
monkeypatch.setattr(home, 'now', lambda: datetime.fromisoformat('2026-09-12T12:15:00+00:00'))
|
||||||
|
assert (await logged_in.post(PREFIX + '/messages/generate')).status_code == 409
|
||||||
|
reloaded = (await logged_in.get(PREFIX + '/messages')).json()
|
||||||
|
assert reloaded['analysis'] is None and len(platform_calls) == 4
|
||||||
|
assert model_calls == ['basic-model']
|
||||||
|
|
||||||
|
|
||||||
|
async def test_message_window_rechecks_memory_and_removes_expired_interpretation(app, logged_in, monkeypatch):
|
||||||
|
await setup(app, monkeypatch)
|
||||||
|
monkeypatch.setattr(home, 'now', lambda: datetime.fromisoformat('2026-09-12T12:00:00+00:00'))
|
||||||
|
async def upstream(path, params=None):
|
||||||
|
return page([{'id': 'boundary', 'dateCreated': '2026-08-12T08:00:00-04:00'}])
|
||||||
|
monkeypatch.setattr(app.state.runner.client, 'get', upstream)
|
||||||
|
assert (await logged_in.get(PREFIX + '/messages')).json()['content']['total'] == 1
|
||||||
|
app.state.home_message_cache['TEST_USER'].analyses[0] = {'old': 'interpretation'}
|
||||||
|
monkeypatch.setattr(home, 'now', lambda: datetime.fromisoformat('2026-09-12T12:00:01+00:00'))
|
||||||
|
result = (await logged_in.get(PREFIX + '/messages')).json()
|
||||||
|
assert result['content']['total'] == 0 and result['analysis'] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_message_cleanup_migration_only_removes_message_cache(tmp_path):
|
||||||
|
path = Path(__file__).parents[1] / 'migrations/versions/0017_remove_message_cache.py'
|
||||||
|
spec = importlib.util.spec_from_file_location('message_cleanup', path)
|
||||||
|
migration = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(migration)
|
||||||
|
engine = sa.create_engine(f'sqlite:///{tmp_path}/cleanup.db')
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.exec_driver_sql('CREATE TABLE home_information (user_id TEXT, resource TEXT, content TEXT, analysis TEXT)')
|
||||||
|
for user, resource in [('A', 'messages:0'), ('B', 'messages:10'), ('A', 'events:'), ('A', 'competition:ARC2026')]:
|
||||||
|
conn.exec_driver_sql('INSERT INTO home_information VALUES (?, ?, ?, ?)', (user, resource, 'source', 'ai'))
|
||||||
|
with Operations.context(MigrationContext.configure(conn)):
|
||||||
|
migration.upgrade()
|
||||||
|
migration.upgrade()
|
||||||
|
assert conn.exec_driver_sql('SELECT resource FROM home_information ORDER BY resource').scalars().all() == ['competition:ARC2026', 'events:']
|
||||||
|
engine.dispose()
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ type Analysis = {
|
|||||||
outdated: boolean;
|
outdated: boolean;
|
||||||
};
|
};
|
||||||
type Snapshot = {
|
type Snapshot = {
|
||||||
|
ephemeral?: boolean;
|
||||||
content: Content | null;
|
content: Content | null;
|
||||||
fetched_at: string | null;
|
fetched_at: string | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
@@ -243,7 +244,9 @@ function Interpretation({ state, zone }: { state: Resource; zone: string }) {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="home-info-muted">
|
<p className="home-info-muted">
|
||||||
手动生成并保存,重新打开首页不会调用模型。
|
{state.data?.ephemeral
|
||||||
|
? "解读仅暂存在内存,不写入数据库;重启或缓存到期后清除。"
|
||||||
|
: "手动生成并保存,重新打开首页不会调用模型。"}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -348,7 +351,9 @@ function MessagePage({
|
|||||||
state={state}
|
state={state}
|
||||||
zone={zone}
|
zone={zone}
|
||||||
>
|
>
|
||||||
<p className="home-info-muted">来自平台消息的公告、更新及社区资讯。</p>
|
<p className="home-info-muted">
|
||||||
|
仅展示最近一个月的公告、更新及社区资讯(按美东时间筛选),不写入数据库。
|
||||||
|
</p>
|
||||||
{content?.items?.length ? (
|
{content?.items?.length ? (
|
||||||
<div className="home-info-feed">
|
<div className="home-info-feed">
|
||||||
{content.items.map((item, index) => (
|
{content.items.map((item, index) => (
|
||||||
@@ -376,13 +381,15 @@ function MessagePage({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Empty>本页暂无平台消息</Empty>
|
<Empty>最近一个月本页暂无平台消息</Empty>
|
||||||
)}
|
)}
|
||||||
<div className="home-info-row home-info-pagination">
|
<div className="home-info-row home-info-pagination">
|
||||||
<span className="home-info-muted">
|
<span className="home-info-muted">
|
||||||
从第 {offset + 1} 条开始
|
从第 {offset + 1} 条开始
|
||||||
{content?.total != null ? ` · 平台共 ${content.total} 条` : ""} ·
|
{content?.total != null
|
||||||
解读仅针对本页
|
? ` · 最近一个月共 ${content.total} 条`
|
||||||
|
: ""}{" "}
|
||||||
|
· 解读仅针对本页
|
||||||
</span>
|
</span>
|
||||||
<div className="home-info-actions">
|
<div className="home-info-actions">
|
||||||
<Button size="small" disabled={!back || !!state.busy} onClick={back}>
|
<Button size="small" disabled={!back || !!state.busy} onClick={back}>
|
||||||
|
|||||||
Executable
+80
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
# 修改这三个值即可。
|
||||||
|
REGION="EUR"
|
||||||
|
UNIVERSE="TOP2500"
|
||||||
|
DELAY="1"
|
||||||
|
|
||||||
|
PROJECT_NAME="${COMPOSE_PROJECT_NAME:-wq-alpha-production}"
|
||||||
|
BACKEND_PYTHON="/app/.venv/bin/python"
|
||||||
|
|
||||||
|
if ! docker info >/dev/null; then
|
||||||
|
echo "ERROR: 无法连接 Docker,请检查 1Panel 任务的 Docker 访问权限" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 先检查 docker ps 的退出状态;进程替换会掩盖查询失败。
|
||||||
|
if ! CONTAINER_NAMES="$(
|
||||||
|
docker ps \
|
||||||
|
--filter "status=running" \
|
||||||
|
--filter "label=com.docker.compose.project=${PROJECT_NAME}" \
|
||||||
|
--filter "label=com.docker.compose.service=backend" \
|
||||||
|
--format '{{.Names}}'
|
||||||
|
)"; then
|
||||||
|
echo "ERROR: 查询 production backend 容器失败" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BACKEND_CONTAINERS=()
|
||||||
|
while IFS= read -r container; do
|
||||||
|
if [ -n "$container" ]; then
|
||||||
|
BACKEND_CONTAINERS+=("$container")
|
||||||
|
fi
|
||||||
|
done <<< "$CONTAINER_NAMES"
|
||||||
|
|
||||||
|
if [ "${#BACKEND_CONTAINERS[@]}" -eq 0 ]; then
|
||||||
|
echo "ERROR: 没有找到 production backend 容器,Compose 项目名:${PROJECT_NAME}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "${#BACKEND_CONTAINERS[@]}" -gt 1 ]; then
|
||||||
|
echo "ERROR: 找到多个 production backend 容器,拒绝选择:" >&2
|
||||||
|
printf ' %s\n' "${BACKEND_CONTAINERS[@]}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BACKEND_CONTAINER="${BACKEND_CONTAINERS[0]}"
|
||||||
|
HEALTH_STATUS="$(
|
||||||
|
docker inspect \
|
||||||
|
--format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' \
|
||||||
|
"$BACKEND_CONTAINER"
|
||||||
|
)"
|
||||||
|
if [ "$HEALTH_STATUS" != "healthy" ]; then
|
||||||
|
echo "ERROR: backend 容器当前不健康,状态:${HEALTH_STATUS}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! docker exec "$BACKEND_CONTAINER" test -x "$BACKEND_PYTHON"; then
|
||||||
|
echo "ERROR: 容器中不存在可执行命令:${BACKEND_PYTHON}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! docker exec --workdir /app "$BACKEND_CONTAINER" \
|
||||||
|
"$BACKEND_PYTHON" -m app.cli catalog-sync --help >/dev/null; then
|
||||||
|
echo "ERROR: catalog-sync CLI 不可用,请确认已部署包含全量目录同步的镜像" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! docker exec "$BACKEND_CONTAINER" sh -c \
|
||||||
|
'test -n "${DATABASE_URL:-}" && test -n "${ENCRYPTION_KEY:-}" && test -n "${ADMIN_PASSWORD:-}"'; then
|
||||||
|
echo "ERROR: 容器缺少 DATABASE_URL、ENCRYPTION_KEY 或 ADMIN_PASSWORD" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "使用容器:${BACKEND_CONTAINER}"
|
||||||
|
echo "开始全量同步:${REGION} / ${UNIVERSE} / Delay ${DELAY}"
|
||||||
|
|
||||||
|
# 沿用容器环境和现有后台执行器,不创建另一套同步进程。
|
||||||
|
# 保留 CLI 退出码;等待超时不取消后台任务。
|
||||||
|
exec docker exec --workdir /app \
|
||||||
|
"$BACKEND_CONTAINER" \
|
||||||
|
"$BACKEND_PYTHON" -u -m app.cli catalog-sync \
|
||||||
|
--region "$REGION" --universe "$UNIVERSE" --delay "$DELAY"
|
||||||
Reference in New Issue
Block a user