项目文件夹

文件
wehub-resource-sync 09e9f3545f
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
dependency-audit / pip-audit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

449 行
18 KiB
Python

"""Research row adapters for the ``POLL_RESEARCH`` (``e3bVqc``) payload.
These adapters centralise the positional knowledge that
``_research_task_parser.py`` previously open-coded as scattered single-level
subscripts (``result[0]``, ``src[1]``, ``bundle[0]``, ``task_info[1][0]`` …).
The parser wraps the raw lists in the typed views below and reads named
properties so a future Google reshape of the research wire format is a
one-place fix here, and so genuine drift on the *guaranteed* descents RAISES
``UnknownRPCMethodError`` via ``safe_index`` instead of silently degrading to
empty/wrong data (ADR-0011).
Two descent flavours are preserved exactly as the historical parser had them:
* **Guaranteed** slots — the two leading slots of a task row (``task_data[0]``
task id, ``task_data[1]`` task info) — descend through ``safe_index`` so an
absent slot RAISES (that is genuine shape drift; the task cannot be parsed
without them).
* **Routinely-optional** slots — every research-source field and the
query / sources / summary bundle reads — are length-guarded *inside* the
adapter and short-circuit to a default, matching the parser's permissive
contract (a deep-research source legitimately omits its URL, a fast-research
task legitimately omits its summary, …).
Position contracts (pinned by ``tests/unit/test_research_row_adapter.py``):
* :class:`ResearchTaskRow` — one task row (``tasks[i]``):
===== ============================================================
Index Meaning
===== ============================================================
0 task id (str) — GUARANTEED (``safe_index``)
1 task info block (list) — GUARANTEED (``safe_index``)
===== ============================================================
* :class:`ResearchTaskInfoRow` — one task info block (``task_data[1]``):
===== ============================================================
Index Meaning
===== ============================================================
1 query block; ``[1][0]`` is the original query text
3 sources/summary bundle; ``[3][0]`` sources list, ``[3][1]`` summary
4 status code (int)
===== ============================================================
* :class:`ResearchResultRow` — one source row (``sources_data[i]``):
===== ============================================================
Index Meaning
===== ============================================================
0 URL (str) for fast research; ``None`` sentinel for deep research
1 title (str) — or, for current deep research, ``[title, report]``
3 authoritative result-type tag
6 legacy deep-research report chunks (list of str)
===== ============================================================
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, ClassVar
from ..rpc import RPCMethod, safe_index
__all__ = [
"ImportedSourceRow",
"ResearchResultRow",
"ResearchStartRow",
"ResearchTaskInfoRow",
"ResearchTaskRow",
"unwrap_import_rows",
"unwrap_poll_tasks",
]
# The ``POLL_RESEARCH`` method id and a stable source label, threaded into
# ``safe_index`` so drift on the guaranteed descents points at the right RPC.
_POLL_METHOD_ID = RPCMethod.POLL_RESEARCH.value
# Envelope-unwrap positions: ``POLL_RESEARCH`` returns either a wrapped
# envelope (``[[task1, ...]]``) or an already-flat list of tasks.
_ENVELOPE_OUTER_POS = 0
_ENVELOPE_PROBE_POS = 0
def unwrap_poll_tasks(result: Any) -> list[Any]:
"""Return the flat list of task rows from a raw ``POLL_RESEARCH`` result.
``POLL_RESEARCH`` returns either a wrapped envelope (``[[task1, ...]]``) or
an already-flat list of tasks. This centralises that ``result[0]`` /
``result[0][0]`` envelope probe so the parser stops open-coding it; the
reads are soft (an unrecognised shape falls through to ``result`` unchanged
or ``[]``), preserving the historical ``_unwrap_poll_result`` contract.
"""
if not result or not isinstance(result, list):
return []
first = result[_ENVELOPE_OUTER_POS]
if isinstance(first, list) and len(first) > 0 and isinstance(first[_ENVELOPE_PROBE_POS], list):
return first
return result
@dataclass(frozen=True)
class ResearchTaskRow:
"""Typed view of one raw ``POLL_RESEARCH`` task row (``tasks[i]``).
The two leading slots are GUARANTEED: a task that is missing its id
(``task_data[0]``) or its info block (``task_data[1]``) is genuine shape
drift, so both reads descend through ``safe_index`` and RAISE
``UnknownRPCMethodError`` on absence — mirroring the historical
``_extract_task_id`` / ``_extract_task_info`` contract exactly (an empty or
non-list ``task_data`` raised; a present-but-wrong-type value logged and
degraded to ``None``).
"""
_raw: Any = field(repr=False)
_ID_POS: ClassVar[int] = 0
_INFO_POS: ClassVar[int] = 1
_ID_SOURCE: ClassVar[str] = "ResearchTaskRow.task_id"
_INFO_SOURCE: ClassVar[str] = "ResearchTaskRow.task_info"
@property
def task_id_raw(self) -> Any:
"""Raw value at ``task_data[0]`` (str validated by the caller).
Descends through ``safe_index`` so an absent id slot RAISES — a task
row that cannot supply its id is genuine drift, not a soft default.
"""
return safe_index(
self._raw, self._ID_POS, method_id=_POLL_METHOD_ID, source=self._ID_SOURCE
)
@property
def task_info_raw(self) -> Any:
"""Raw value at ``task_data[1]`` (list validated by the caller).
Descends through ``safe_index`` so an absent info slot RAISES — a task
row without its info block is genuine drift.
"""
return safe_index(
self._raw, self._INFO_POS, method_id=_POLL_METHOD_ID, source=self._INFO_SOURCE
)
class ResearchTaskInfoRow:
"""Typed view of one task info block (``task_data[1]``).
The ``[1]`` query block, ``[3]`` sources/summary bundle, and ``[4]`` status
code are themselves read through ``safe_index`` by the parser's
``_extract_*`` helpers (an absent slot is drift). This adapter centralises
the *inner* single-level reads those helpers then perform on the bound
blocks — ``query_info[0]``, ``bundle[0]``, ``bundle[1]`` — which are
routinely-optional and short-circuit to a default rather than raising.
"""
_QUERY_TEXT_POS: ClassVar[int] = 0
_SOURCES_POS: ClassVar[int] = 0
_SUMMARY_POS: ClassVar[int] = 1
_SUMMARY_MIN_LEN: ClassVar[int] = 2
@staticmethod
def query_text(query_info: Any) -> Any:
"""First element of the query block (``task_info[1][0]``) or ``None``.
``query_info`` is ``task_info[1]`` (already validated as a list by the
caller). An empty block legitimately means "no query text", so this
short-circuits to ``None`` rather than raising.
"""
return query_info[ResearchTaskInfoRow._QUERY_TEXT_POS] if query_info else None
@staticmethod
def bundle_sources(bundle: Any) -> Any:
"""Sources list at ``task_info[3][0]`` — ``None`` when the slot is absent.
``bundle`` is ``task_info[3]``. The parser's caller already
short-circuits an empty bundle, but the adapter still length-guards its
own read (mirroring :meth:`bundle_summary`) so an empty bundle degrades
to the missing-slot default instead of raising ``IndexError`` — the
soft-read contract this module documents. The caller coerces a
non-list (including ``None``) to ``[]``.
"""
if len(bundle) <= ResearchTaskInfoRow._SOURCES_POS:
return None
return bundle[ResearchTaskInfoRow._SOURCES_POS]
@staticmethod
def bundle_summary(bundle: Any) -> Any:
"""Summary at ``task_info[3][1]`` — ``None`` when the slot is absent.
A sources-only bundle legitimately omits the summary, so this is a
length-guarded soft read (``[1]`` only when ``len(bundle) >= 2``).
"""
if len(bundle) < ResearchTaskInfoRow._SUMMARY_MIN_LEN:
return None
return bundle[ResearchTaskInfoRow._SUMMARY_POS]
@dataclass(frozen=True)
class ResearchResultRow:
"""Typed view of one raw research source row (``sources_data[i]``).
The source row carries three shapes the historical parser handled inline:
* fast research — ``[url, title, desc, type, ...]``
* deep research (legacy) — ``[None, title, None, type, ..., [report_md]]``
* deep research (current) — ``[None, [title, report_md], None, type, ...]``
Every field is routinely-optional (a deep-research row omits its URL; a
fast-research row omits the report), so the adapter length-guards each read
and short-circuits to a default — preserving the parser's permissive
contract (a malformed source is skipped, never raised). Position knowledge
is centralised here; the parser reads named properties instead of
``src[0]`` / ``src[1]`` / ``src[3]`` / ``src[6]``.
"""
_raw: Any = field(repr=False)
_URL_POS: ClassVar[int] = 0
_TITLE_POS: ClassVar[int] = 1
_RESULT_TYPE_POS: ClassVar[int] = 3
_LEGACY_CHUNKS_POS: ClassVar[int] = 6
# A source row must carry at least ``[url/sentinel, title]`` to be usable —
# mirrors the historical ``len(src) < 2`` early return.
_MIN_LEN: ClassVar[int] = 2
# Layout of the current-deep-research ``[title, report_markdown]`` payload
# packed at ``src[1]``.
_PAYLOAD_TITLE_POS: ClassVar[int] = 0
_PAYLOAD_REPORT_POS: ClassVar[int] = 1
_PAYLOAD_MIN_LEN: ClassVar[int] = 2
@property
def is_well_formed(self) -> bool:
"""Whether the row is a list long enough to carry url/sentinel + title."""
return isinstance(self._raw, list) and len(self._raw) >= self._MIN_LEN
@property
def length(self) -> int:
"""Length of the raw row (``0`` when not a list).
Exposed so the parser can preserve its ``len(src) > 3`` / ``len(src) >= 3``
branch conditions without re-reaching for ``self._raw``.
"""
return len(self._raw) if isinstance(self._raw, list) else 0
@property
def url_slot(self) -> Any:
"""Raw value at ``src[0]`` — URL (str) for fast research, ``None`` sentinel
for deep research. ``None`` when the slot is absent."""
if self.length <= self._URL_POS:
return None
return self._raw[self._URL_POS]
@property
def title_slot(self) -> Any:
"""Raw value at ``src[1]`` — a title ``str`` or, for current deep
research, the ``[title, report_markdown]`` payload. ``None`` when absent."""
if self.length <= self._TITLE_POS:
return None
return self._raw[self._TITLE_POS]
@property
def result_type_slot(self) -> Any:
"""Authoritative result-type tag at ``src[3]`` — ``None`` when absent.
The caller (``parse_result_type``) is responsible for normalising the
value; the adapter only reports presence. ``None`` here makes the parser
fall back to the web default exactly as ``len(src) > 3`` did.
"""
if self.length <= self._RESULT_TYPE_POS:
return None
return self._raw[self._RESULT_TYPE_POS]
@property
def has_result_type(self) -> bool:
"""Whether ``src[3]`` is present (``len(src) > 3``)."""
return self.length > self._RESULT_TYPE_POS
@property
def legacy_report_chunks(self) -> list[Any]:
"""Legacy deep-research report chunks at ``src[6]`` — ``[]`` when absent/non-list."""
if self.length <= self._LEGACY_CHUNKS_POS:
return []
value = self._raw[self._LEGACY_CHUNKS_POS]
return value if isinstance(value, list) else []
@staticmethod
def deep_payload(payload: Any) -> tuple[str, str] | None:
"""Unpack a current-deep-research ``[title, report_markdown]`` payload.
Returns ``(title, report_markdown)`` only when ``payload`` is a list of
at least two strings (the exact shape the historical parser required at
``src[1]``); otherwise ``None`` so the caller falls through to the
bare-string-title / fast-research branches.
"""
if (
isinstance(payload, list)
and len(payload) >= ResearchResultRow._PAYLOAD_MIN_LEN
and isinstance(payload[ResearchResultRow._PAYLOAD_TITLE_POS], str)
and isinstance(payload[ResearchResultRow._PAYLOAD_REPORT_POS], str)
):
return (
payload[ResearchResultRow._PAYLOAD_TITLE_POS],
payload[ResearchResultRow._PAYLOAD_REPORT_POS],
)
return None
@dataclass(frozen=True)
class ResearchStartRow:
"""Typed view of a ``START_FAST_RESEARCH`` / ``START_DEEP_RESEARCH`` result.
The kickoff RPCs return ``[task_id, report_id?, …]``. The caller guards the
row as a non-empty list before constructing this view, so the ``task_id``
slot is GUARANTEED present and descends through ``safe_index`` (an absent id
slot on a non-empty row is genuine drift). The ``report_id`` slot is
routinely-optional (a fast-research start omits it), so it is length-guarded
and short-circuits to ``None``.
Position knowledge is centralised here; ``_research.start`` reads named
properties instead of ``result[0]`` / ``result[1]``.
"""
_raw: Any = field(repr=False)
_TASK_ID_POS: ClassVar[int] = 0
_REPORT_ID_POS: ClassVar[int] = 1
_TASK_ID_SOURCE: ClassVar[str] = "ResearchStartRow.task_id"
@property
def task_id_raw(self) -> Any:
"""Raw value at ``result[0]`` (truthiness validated by the caller).
The caller guarantees a non-empty list before wrapping, so this descent
is a no-op on the happy path; ``safe_index`` only fires if the id slot
itself drifted out (genuine shape drift).
"""
return safe_index(
self._raw,
self._TASK_ID_POS,
method_id=None,
source=self._TASK_ID_SOURCE,
)
@property
def report_id(self) -> Any:
"""Optional value at ``result[1]`` — ``None`` when the slot is absent.
A fast-research start legitimately omits the report id, so this is a
length-guarded soft read (``[1]`` only when ``len(result) > 1``).
"""
if len(self._raw) <= self._REPORT_ID_POS:
return None
return self._raw[self._REPORT_ID_POS]
# ``IMPORT_RESEARCH`` returns either a wrapped envelope (``[[src1, …]]``) or an
# already-flat list of imported-source rows. These positions centralise the
# envelope probe + per-row reads ``import_sources`` previously open-coded.
_IMPORT_ENVELOPE_OUTER_POS = 0
_IMPORT_ENVELOPE_PROBE_POS = 0
_IMPORT_ROW_ID_ENVELOPE_POS = 0
_IMPORT_ROW_TITLE_POS = 1
_IMPORT_ROW_MIN_LEN = 2
def _looks_like_import_row(value: Any) -> bool:
"""Whether ``value`` has the leading slots of an ``IMPORT_RESEARCH`` row."""
if not isinstance(value, list) or len(value) < _IMPORT_ROW_MIN_LEN:
return False
id_envelope = value[_IMPORT_ROW_ID_ENVELOPE_POS]
return id_envelope is None or isinstance(id_envelope, list)
def unwrap_import_rows(result: Any) -> list[Any]:
"""Return the flat list of imported-source rows from an ``IMPORT_RESEARCH`` result.
``IMPORT_RESEARCH`` returns either a wrapped envelope (``[[src1, …]]``) or an
already-flat list of rows. This centralises the ``result[0]`` / ``result[0][0]``
envelope probe so ``import_sources`` stops open-coding it; the reads are soft
(an unrecognised shape falls through to ``result`` unchanged or ``[]``),
with one extra disambiguation: the wrap is recognised only when
``result[0][0]`` itself looks like an imported-source row. That preserves
flat single-row responses like ``[[[id], title]]`` instead of mistaking the
row's id envelope for a wrapper.
"""
if not result or not isinstance(result, list):
return []
first = result[_IMPORT_ENVELOPE_OUTER_POS]
if (
isinstance(first, list)
and len(first) > 0
and _looks_like_import_row(first[_IMPORT_ENVELOPE_PROBE_POS])
):
return first
return result
@dataclass(frozen=True)
class ImportedSourceRow:
"""Typed view of one ``IMPORT_RESEARCH`` imported-source row (``result[i]``).
Each row is ``[[id, …], title, …]``: the id sits inside an envelope at
``[0][0]`` and the title at ``[1]``. Every read is routinely-optional (the
response is documented as incomplete — a row may legitimately omit its id
envelope), so the adapter length-guards each read and short-circuits to a
default, preserving ``import_sources``'s permissive "skip rows without an
id" contract (a malformed row is skipped, never raised). Position knowledge
is centralised here; the consumer reads named properties instead of
``src_data[0]`` / ``id_envelope[0]`` / ``src_data[1]``.
"""
_raw: Any = field(repr=False)
_ID_ENVELOPE_POS: ClassVar[int] = _IMPORT_ROW_ID_ENVELOPE_POS
_ID_POS: ClassVar[int] = 0
_TITLE_POS: ClassVar[int] = _IMPORT_ROW_TITLE_POS
# A usable row must carry at least ``[id_envelope, title]`` — mirrors the
# historical ``len(src_data) >= 2`` guard.
_MIN_LEN: ClassVar[int] = _IMPORT_ROW_MIN_LEN
@property
def is_well_formed(self) -> bool:
"""Whether the row is a list long enough to carry id envelope + title."""
return isinstance(self._raw, list) and len(self._raw) >= self._MIN_LEN
@property
def source_id(self) -> Any:
"""Imported source id at ``src_data[0][0]`` — ``None`` when absent.
An absent / falsy / non-list id envelope legitimately means "skip this
row" (the historical contract), so it short-circuits to ``None`` rather
than raising. The caller keeps the row only when this is truthy.
"""
if not self.is_well_formed:
return None
envelope = self._raw[self._ID_ENVELOPE_POS]
if not envelope or not isinstance(envelope, list):
return None
return envelope[self._ID_POS]
@property
def title_slot(self) -> Any:
"""Raw title at ``src_data[1]`` — ``None`` when the row is malformed."""
if not self.is_well_formed:
return None
return self._raw[self._TITLE_POS]