diff --git a/backend/app/submission.py b/backend/app/submission.py index 5f2ecda..d0a8fbc 100644 --- a/backend/app/submission.py +++ b/backend/app/submission.py @@ -72,8 +72,25 @@ def source(raw): } +def comparable_description(text): + """Canonicalize empty platform placeholders only; preserve all substantive text. + + This comparison key must never replace the reviewed text sent to the platform. + Unknown or partially filled templates remain exact to protect concurrent edits. + """ + empty_template = r"\s*" + r"\s*".join(re.escape(heading.strip()) for heading in HEADINGS) + r"\s*" + return "" if not text.strip() or re.fullmatch(empty_template, text) else text + + def fingerprint(value): - return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False).encode()).hexdigest() + comparable = { + **value, + "sections": { + key: {**item, "description": comparable_description(item["description"])} + for key, item in value["sections"].items() + }, + } + return hashlib.sha256(json.dumps(comparable, sort_keys=True, ensure_ascii=False).encode()).hexdigest() def parse_description(text): @@ -300,7 +317,10 @@ async def run_check(runner, job_id, payload): patch = {} for section, item in current["sections"].items(): before, target = expected["sections"][section], payload["descriptions"][section] - if item["code"] != before["code"] or item["description"] not in (before["description"], target): + if item["code"] != before["code"] or comparable_description(item["description"]) not in ( + comparable_description(before["description"]), + comparable_description(target), + ): raise WqError("平台表达式或 Description 已变化,未覆盖;请刷新后重新核对", "conflict") if item["description"] != target: patch[section] = {"description": target} diff --git a/backend/tests/test_submission.py b/backend/tests/test_submission.py index 946b250..f6dc366 100644 --- a/backend/tests/test_submission.py +++ b/backend/tests/test_submission.py @@ -10,7 +10,7 @@ from pydantic_ai.models.function import FunctionModel from app.alphas import upsert_alpha from app.models import Account, AISettings, Alpha, Job, Research, SelfCorrelation from app.security import cipher -from app.submission import HEADINGS, Description +from app.submission import HEADINGS, Description, comparable_description, fingerprint, source from app.worldquant import WqClient from tests.conftest import alpha @@ -28,6 +28,26 @@ ACCRUAL_FIELDS = { CHECKS = [{"name": "PROD_CORRELATION", "result": "FAIL", "value": 0.8, "limit": 0.7}] +@pytest.mark.parametrize( + "text", + [ + "plain text", + "Idea: actual idea\nRationale for data used:\nRationale for operators used:", + "Idea:\nRationale for data used: actual data\nRationale for operators used:", + "Idea:\nRationale for data used:\nRationale for operators used: actual operator", + "Idea:\nRationale for data used:", + "Rationale for data used:\nIdea:\nRationale for operators used:", + ], +) +def test_substantive_or_unknown_descriptions_remain_exact(text): + assert comparable_description(text) == text + original = source(alpha(regular={"code": "rank(close)", "description": text})) + changed = copy.deepcopy(original) + changed["sections"]["regular"]["description"] = text + " " + assert fingerprint(original) != fingerprint(changed) + assert original["sections"]["regular"]["description"] == text + + class Platform: def __init__(self, raw): self.raw = copy.deepcopy(raw) @@ -141,6 +161,40 @@ async def test_local_correlation_blocks_upstream(app, logged_in, status, stale): assert platform.calls == [] +@pytest.mark.parametrize("kind", ["REGULAR", "SUPER"]) +@pytest.mark.parametrize("refresh", [False, True]) +@pytest.mark.parametrize( + "before,after", + [ + ("", "Idea: \nRationale for data used: \nRationale for operators used:"), + ("Idea:\r\n Rationale for data used:\t Rationale for operators used: ", ""), + (None, " \r\n\t\u00a0"), + ], +) +async def test_empty_description_representations_allow_check(app, logged_in, kind, refresh, before, after): + sections = ["regular"] if kind == "REGULAR" else ["selection", "combo"] + raw = alpha(type=kind, **{key: {"code": "rank(close)", "description": before} for key in sections}) + platform = await setup(app, raw) + state = (await logged_in.get("/api/v1/alphas/alpha1/submission")).json() + for key in sections: + platform.raw[key]["description"] = after + if refresh: + async with app.state.sessions.begin() as db: + await upsert_alpha(db, platform.raw) + target = Description(**FIELDS).text() + response = await logged_in.post( + "/api/v1/alphas/alpha1/submission-check", + json={"snapshot": state["snapshot"], "descriptions": dict.fromkeys(sections, target)}, + ) + assert response.status_code == 202, response.text + await app.state.runner.execute(response.json()["id"]) + async with app.state.sessions() as db: + job = await db.get(Job, response.json()["id"]) + assert job.status == "completed", job.error + assert platform.patches == [{key: {"description": target} for key in sections}] + assert ("GET", "/alphas/alpha1/check") in platform.calls + + @pytest.mark.parametrize("change", ["description", "code", "settings", "status"]) async def test_remote_conflict_never_overwrites(app, logged_in, change): platform = await setup(app)