Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 58 additions & 2 deletions backend/channels/doubao_research_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,18 @@
from backend.channels.base import AbstractChannel, Capabilities, ChannelResult
from backend.channels.registry import register_channel

_URL_RE = re.compile(r"https?://[^\s<>\]\[\](){}\"']+", re.IGNORECASE)
_URL_RE = re.compile(r"https?://[^\s<>\[\](){}'\"]+", re.IGNORECASE)
_TRAILING_URL_PUNCTUATION = ".,;:!?\uff0c\u3002\uff1b\uff1a\uff01\uff1f"
#: OpenCLI adapter reports a captcha wall this way (verified on opencli 1.8.6).
_CAPTCHA_MARKERS = (
"verification challenge",
"captcha",
"blocked the request",
"人机验证",
"验证码",
)


def _citations(text: str) -> list[dict[str, str]]:
"""Extract and de-duplicate URLs while preserving the answer's order."""
seen: set[str] = set()
Expand Down Expand Up @@ -37,6 +47,25 @@ def _answer(rows: list[dict[str, Any]]) -> str:
).strip()


def _conversation_url(stdout: str) -> str:
"""Extract https://www.doubao.com/chat/<id> from `doubao status -f json` output."""
try:
rows = _parse_opencli_rows(stdout)
except Exception:
return ""
for row in rows:
url = str(row.get("Url", row.get("url", "")) or "").strip()
if "/chat/" in url:
return url
Comment on lines +57 to +59

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Validate the extracted conversation URL.

The predicate accepts http URLs, non-Doubao hosts, and /chat/ with no conversation ID. The method then stores these values in conversation_url, although its contract requires an HTTPS www.doubao.com/chat/<id> URL.

Proposed fix
+from urllib.parse import urlsplit
+
-        if "/chat/" in url:
+        parsed = urlsplit(url)
+        chat_id = parsed.path.removeprefix("/chat/").strip("/")
+        if (
+            parsed.scheme == "https"
+            and parsed.hostname == "www.doubao.com"
+            and chat_id
+            and "/" not in chat_id
+        ):
             return url
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
url = str(row.get("Url", row.get("url", "")) or "").strip()
if "/chat/" in url:
return url
from urllib.parse import urlsplit
url = str(row.get("Url", row.get("url", "")) or "").strip()
parsed = urlsplit(url)
chat_id = parsed.path.removeprefix("/chat/").strip("/")
if (
parsed.scheme == "https"
and parsed.hostname == "www.doubao.com"
and chat_id
and "/" not in chat_id
):
return url
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/channels/doubao_research_channel.py` around lines 57 - 59, Update the
URL extraction logic around the visible Url/url lookup so it only returns a
valid HTTPS Doubao conversation URL matching www.doubao.com/chat/<id>. Reject
HTTP URLs, non-Doubao hosts, and paths with no non-empty conversation ID before
assigning the result to conversation_url.

return ""


def _is_captcha_block(stderr: str, stdout: str) -> bool:
"""True when the adapter reports a captcha/verification wall."""
text = f"{stderr} {stdout}".lower()
return any(marker in text for marker in _CAPTCHA_MARKERS)


async def _run_doubao_command(command: list[str]) -> tuple[int, str, str]:
"""Late import avoids the channel registry's legacy OpenCLI import cycle."""
from backend.channels.opencli_channel import _run_opencli
Expand Down Expand Up @@ -102,8 +131,12 @@ async def collect(self, config: dict[str, Any], parameters: dict[str, Any]) -> C
)

if returncode:
# Classify captcha walls so the runner can apply a human-in-the-loop
# or cooldown-retry policy instead of treating it as a permanent failure.
error_type = "captcha_challenge" if _is_captcha_block(stderr, stdout) else None
return ChannelResult.fail(
f"opencli doubao ask exited with code {returncode}: {stderr[:500]}"
f"opencli doubao ask exited with code {returncode}: {stderr[:500]}",
error_type=error_type,
)
try:
answer = _answer(_parse_opencli_rows(stdout))
Expand All @@ -114,6 +147,28 @@ async def collect(self, config: dict[str, Any], parameters: dict[str, Any]) -> C
if not answer:
return ChannelResult.fail("Doubao returned no assistant text")

# Best-effort conversation URL: `doubao status -f json` exposes the
# active chat id (https://www.doubao.com/chat/<id>). This is a
# read-only query against the same browser session; a failure here
# must not fail the collect — the answer is already in hand.
conversation_url = ""
if config.get("capture_conversation_url", True):
status_command = [
_opencli_binary(),
"doubao",
"status",
"-f",
"json",
"--site-session",
str(config.get("site_session", "ephemeral")),
]
try:
rc, so, se = await _run_doubao_command(status_command)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the unused status-error binding.

Line 166 binds se but does not use it. Ruff reports RUF059.

Proposed fix
-                rc, so, se = await _run_doubao_command(status_command)
+                rc, so, _ = await _run_doubao_command(status_command)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
rc, so, se = await _run_doubao_command(status_command)
rc, so, _ = await _run_doubao_command(status_command)
🧰 Tools
🪛 Ruff (0.16.1)

[warning] 166-166: Unpacked variable se is never used

Prefix it with an underscore or any other dummy variable pattern

(RUF059)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/channels/doubao_research_channel.py` at line 166, Update the tuple
unpacking in the command execution flow around _run_doubao_command to discard
the unused status-error value instead of binding it to se, resolving Ruff RUF059
while preserving rc and so handling.

Source: Linters/SAST tools

if rc == 0:
conversation_url = _conversation_url(so)
except Exception:
conversation_url = ""

citations = _citations(answer) if extract_citations else []
return ChannelResult.ok(
[
Expand All @@ -122,6 +177,7 @@ async def collect(self, config: dict[str, Any], parameters: dict[str, Any]) -> C
"content": answer,
"author": "doubao",
"question": question,
"conversation_url": conversation_url,
"citations": citations,
"citation_count": len(citations),
"citation_capture": (
Expand Down
97 changes: 96 additions & 1 deletion tests/unit/channels/test_doubao_research_channel.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import pytest

from backend.channels.doubao_research_channel import DoubaoResearchChannel, _citations
from backend.channels.doubao_research_channel import (
DoubaoResearchChannel,
_citations,
_conversation_url,
)
from backend.schemas.source import DataSourceCreate


Expand All @@ -13,6 +17,29 @@ def test_citations_preserve_order_and_strip_punctuation():
]


def test_conversation_url_extracts_chat_id():
status = (
'[{"Status": "Connected", "Url": '
'"https://www.doubao.com/chat/38436240748612354", "Title": "x"}]'
)
assert (
_conversation_url(status)
== "https://www.doubao.com/chat/38436240748612354"
)


def test_conversation_url_ignores_root_chat():
# A freshly opened /chat page has no conversation id yet — must not be picked up.
status = (
'[{"Status": "Connected", "Url": "https://www.doubao.com/chat", "Title": "x"}]'
)
assert _conversation_url(status) == ""


def test_conversation_url_tolerates_garbage():
assert _conversation_url("not json at all") == ""


@pytest.mark.asyncio
async def test_collect_stores_answer_and_citations(monkeypatch):
async def fake_run(command):
Expand Down Expand Up @@ -48,8 +75,76 @@ async def fake_run(command):
assert result.items[0]["citations"] == [{"url": "https://example.com/"}]


@pytest.mark.asyncio
async def test_collect_captures_conversation_url(monkeypatch):
calls = []

async def fake_run(command):
calls.append(command)
if command[2] == "ask":
return 0, '[{"Role":"assistant","Text":"回答"}]', ""
if command[2] == "status":
return 0, (
'[{"Status": "Connected", "Url": '
'"https://www.doubao.com/chat/12345", "Title": "t"}]'
), ""
return 0, "", ""

monkeypatch.setattr("backend.channels.doubao_research_channel._run_doubao_command", fake_run)
result = await DoubaoResearchChannel().collect({"question": "测试"}, {})

assert result.success
assert result.items[0]["conversation_url"] == "https://www.doubao.com/chat/12345"
# ask + status both hit the adapter
assert [c[2] for c in calls] == ["ask", "status"]


@pytest.mark.asyncio
async def test_collect_tolerates_status_failure(monkeypatch):
async def fake_run(command):
if command[2] == "ask":
return 0, '[{"Role":"assistant","Text":"回答"}]', ""
return 1, "", "status exploded"

monkeypatch.setattr("backend.channels.doubao_research_channel._run_doubao_command", fake_run)
result = await DoubaoResearchChannel().collect({"question": "测试"}, {})

# A failed status must NOT fail the collect — answer is already in hand.
assert result.success
assert result.items[0]["conversation_url"] == ""


@pytest.mark.asyncio
async def test_collect_classifies_captcha_block(monkeypatch):
async def fake_run(command):
return 1, "", (
"ok: false\nerror:\n code: COMMAND_EXEC\n"
" message: Doubao blocked the request with a verification challenge\n"
" help: 'Detected challenge signal: iframe[src*=\"captcha\"]'"
)

monkeypatch.setattr("backend.channels.doubao_research_channel._run_doubao_command", fake_run)
result = await DoubaoResearchChannel().collect({"question": "测试"}, {})

assert not result.success
assert result.error_type == "captcha_challenge"


@pytest.mark.asyncio
async def test_collect_does_not_classify_generic_error(monkeypatch):
async def fake_run(command):
return 1, "", "some unrelated error"

monkeypatch.setattr("backend.channels.doubao_research_channel._run_doubao_command", fake_run)
result = await DoubaoResearchChannel().collect({"question": "测试"}, {})

assert not result.success
assert result.error_type is None


def test_source_schema_accepts_doubao_research_channel():
source = DataSourceCreate(
name="Doubao research", channel_type="doubao_research", channel_config={"question": "test"}
)
assert source.channel_type == "doubao_research"