Compare commits
7 Commits
session/CF
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5f54df42b | ||
|
|
8b7cf46312 | ||
|
|
7087fbf733 | ||
|
|
f586dd1fc8 | ||
|
|
e41a3bff78 | ||
|
|
0c0a424004 | ||
|
|
6d79b184b9 |
@@ -19,6 +19,13 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pip install -r requirements.txt -r requirements-test.txt
|
run: pip install -r requirements.txt -r requirements-test.txt
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
|
env:
|
||||||
|
MATRIX_HOMESERVER: https://test.local
|
||||||
|
MATRIX_BOT_USER: "@test:test.local"
|
||||||
|
MATRIX_BOT_PASSWORD: test
|
||||||
|
LIVEKIT_URL: wss://test.local
|
||||||
|
LIVEKIT_API_KEY: test
|
||||||
|
LIVEKIT_API_SECRET: test
|
||||||
run: pytest tests/ -v --cov=device_trust --cov-report=term
|
run: pytest tests/ -v --cov=device_trust --cov-report=term
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
needs: [test]
|
needs: [test]
|
||||||
|
|||||||
@@ -17,5 +17,12 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
pip install -r requirements.txt -r requirements-test.txt
|
pip install -r requirements.txt -r requirements-test.txt
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
|
env:
|
||||||
|
MATRIX_HOMESERVER: https://test.local
|
||||||
|
MATRIX_BOT_USER: "@test:test.local"
|
||||||
|
MATRIX_BOT_PASSWORD: test
|
||||||
|
LIVEKIT_URL: wss://test.local
|
||||||
|
LIVEKIT_API_KEY: test
|
||||||
|
LIVEKIT_API_SECRET: test
|
||||||
run: |
|
run: |
|
||||||
pytest tests/ -v --cov=device_trust --cov-report=term
|
pytest tests/ -v --cov=device_trust --cov-report=term
|
||||||
|
|||||||
@@ -28,6 +28,16 @@ _DISCUSS_KW = {"discuss", "diskutieren", "besprechen", "reden", "talk", "chat"}
|
|||||||
_TEXT_KW = {"text", "zusammenfassung", "summary", "lesen", "read", "schriftlich", "written"}
|
_TEXT_KW = {"text", "zusammenfassung", "summary", "lesen", "read", "schriftlich", "written"}
|
||||||
_AUDIO_KW = {"audio", "mp3", "anhören", "vorlesen", "hören", "listen", "blinkist", "abspielen", "podcast"}
|
_AUDIO_KW = {"audio", "mp3", "anhören", "vorlesen", "hören", "listen", "blinkist", "abspielen", "podcast"}
|
||||||
|
|
||||||
|
# Words that signal the user actually wants the article-summary FSM to engage.
|
||||||
|
# Without one of these, a pasted URL is left alone (chat-as-usual).
|
||||||
|
# Union of discuss/text/audio keywords + explicit summary asks.
|
||||||
|
_INTENT_KW = (
|
||||||
|
_DISCUSS_KW | _TEXT_KW | _AUDIO_KW |
|
||||||
|
{"tldr", "tl;dr", "fasse zusammen", "fass zusammen", "zusammenfassen",
|
||||||
|
"summarise", "summarize", "worum geht", "was steht", "what does it say",
|
||||||
|
"kannst du das lesen", "lies das", "lies mir", "read this", "read it"}
|
||||||
|
)
|
||||||
|
|
||||||
# Simple German detection: common words that appear frequently in German text
|
# Simple German detection: common words that appear frequently in German text
|
||||||
_DE_INDICATORS = {"der", "die", "das", "und", "ist", "ein", "eine", "für", "mit", "auf", "den", "dem", "sich", "nicht", "von", "wird", "auch", "nach", "wie", "aber"}
|
_DE_INDICATORS = {"der", "die", "das", "und", "ist", "ein", "eine", "für", "mit", "auf", "den", "dem", "sich", "nicht", "von", "wird", "auch", "nach", "wie", "aber"}
|
||||||
|
|
||||||
@@ -160,13 +170,21 @@ class ArticleSummaryHandler:
|
|||||||
async def _check_for_url(
|
async def _check_for_url(
|
||||||
self, room_id: str, sender: str, body: str
|
self, room_id: str, sender: str, body: str
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Check if message contains an article URL."""
|
"""Check if message contains an article URL AND explicit summary intent."""
|
||||||
urls = URL_PATTERN.findall(body)
|
urls = URL_PATTERN.findall(body)
|
||||||
# Filter to article-like URLs
|
# Filter to article-like URLs
|
||||||
article_urls = [u for u in urls if is_article_url(u)]
|
article_urls = [u for u in urls if is_article_url(u)]
|
||||||
if not article_urls:
|
if not article_urls:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Only engage the FSM if the user explicitly asked for a summary /
|
||||||
|
# discussion / audio. Otherwise a pasted URL is just context for normal
|
||||||
|
# chat and we shouldn't burn a Firecrawl + LLM topic-detection call,
|
||||||
|
# nor interrupt with the 3-option menu.
|
||||||
|
body_lower = body.lower()
|
||||||
|
if not any(kw in body_lower for kw in _INTENT_KW):
|
||||||
|
return None
|
||||||
|
|
||||||
url = article_urls[0]
|
url = article_urls[0]
|
||||||
session = self.sessions.get(sender, room_id)
|
session = self.sessions.get(sender, room_id)
|
||||||
|
|
||||||
|
|||||||
63
bot.py
63
bot.py
@@ -3217,11 +3217,12 @@ class Bot:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
reply = ""
|
reply = ""
|
||||||
|
last_sent_text = ""
|
||||||
streamed_event_id: str | None = None # set when streaming has already posted a message in Matrix
|
streamed_event_id: str | None = None # set when streaming has already posted a message in Matrix
|
||||||
|
|
||||||
# Agentic tool-calling loop: iterate up to MAX_TOOL_ITERATIONS
|
# Agentic tool-calling loop: iterate up to MAX_TOOL_ITERATIONS
|
||||||
for iteration in range(MAX_TOOL_ITERATIONS):
|
for iteration in range(MAX_TOOL_ITERATIONS):
|
||||||
content, tool_calls, usage, streamed_event_id = await self._stream_chat_completion(
|
content, tool_calls, usage, streamed_event_id, last_sent_text = await self._stream_chat_completion(
|
||||||
room_id=room.room_id,
|
room_id=room.room_id,
|
||||||
model=model,
|
model=model,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
@@ -3242,6 +3243,12 @@ class Bot:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Empty response with no tool calls — retry once with escalation model
|
||||||
|
if not content and not tool_calls and model != ESCALATION_MODEL:
|
||||||
|
logger.warning("[empty-response] %s returned nothing, retrying with %s", model, ESCALATION_MODEL)
|
||||||
|
model = ESCALATION_MODEL
|
||||||
|
continue
|
||||||
|
|
||||||
if not tool_calls:
|
if not tool_calls:
|
||||||
# No tool calls — final text response
|
# No tool calls — final text response
|
||||||
break
|
break
|
||||||
@@ -3276,12 +3283,37 @@ class Bot:
|
|||||||
if iteration > 0:
|
if iteration > 0:
|
||||||
sentry_sdk.set_tag("used_tools", "true")
|
sentry_sdk.set_tag("used_tools", "true")
|
||||||
|
|
||||||
# Send / finalize reply. If we streamed, just do a final edit so the
|
# If the loop exhausted MAX_TOOL_ITERATIONS while the model was still
|
||||||
# Matrix message reflects the complete text (otherwise progressive
|
# requesting tools, `reply` is empty and tool results sit unsummarized
|
||||||
# throttling may have stopped short of the last tokens).
|
# in `messages`. Force one final text-only turn so the user sees a
|
||||||
|
# synthesis instead of the dangling preamble we already streamed.
|
||||||
|
if not reply and tool_calls:
|
||||||
|
logger.info(
|
||||||
|
"[stream] hit MAX_TOOL_ITERATIONS=%d still requesting tools; forcing final summary",
|
||||||
|
MAX_TOOL_ITERATIONS,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
final_resp = await self.llm.chat.completions.create(
|
||||||
|
model=model,
|
||||||
|
messages=messages + [{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Bitte fasse jetzt deine Recherche zusammen — keine weiteren Tool-Aufrufe.",
|
||||||
|
}],
|
||||||
|
max_tokens=2048,
|
||||||
|
tools=None,
|
||||||
|
)
|
||||||
|
reply = (final_resp.choices[0].message.content or "").strip()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("[stream] forced final-summary call failed", exc_info=True)
|
||||||
|
reply = "_(Recherche lief in Tool-Schleife — bitte gezielter nachfragen.)_"
|
||||||
|
|
||||||
|
# Send / finalize reply. If we streamed, do a final edit only if
|
||||||
|
# the complete text differs from what was last sent (avoids the
|
||||||
|
# "(bearbeitet)" / "(edited)" indicator for unchanged messages).
|
||||||
if reply:
|
if reply:
|
||||||
if streamed_event_id:
|
if streamed_event_id:
|
||||||
await self._send_stream_edit(room.room_id, streamed_event_id, reply, final=True)
|
if reply != last_sent_text:
|
||||||
|
await self._send_stream_edit(room.room_id, streamed_event_id, reply, final=True)
|
||||||
else:
|
else:
|
||||||
await self._send_text(room.room_id, reply)
|
await self._send_text(room.room_id, reply)
|
||||||
|
|
||||||
@@ -3728,24 +3760,28 @@ class Bot:
|
|||||||
messages: list[dict],
|
messages: list[dict],
|
||||||
tools: list | None,
|
tools: list | None,
|
||||||
prior_event_id: str | None = None,
|
prior_event_id: str | None = None,
|
||||||
) -> tuple[str, list[dict] | None, dict | None, str | None]:
|
) -> tuple[str, list[dict] | None, dict | None, str | None, str]:
|
||||||
"""Stream one chat completion turn.
|
"""Stream one chat completion turn.
|
||||||
|
|
||||||
Progressively edits a Matrix message as content tokens arrive (unless
|
Progressively edits a Matrix message as content tokens arrive (unless
|
||||||
tool_calls have started — those suppress visible streaming until the
|
tool_calls have started — those suppress visible streaming until the
|
||||||
model settles on plain text on a later iteration).
|
model settles on plain text on a later iteration).
|
||||||
|
|
||||||
Returns (content, tool_calls or None, usage dict or None, event_id).
|
Returns (content, tool_calls or None, usage dict or None, event_id, last_sent_text).
|
||||||
`event_id` is the Matrix event we've been streaming into, or None if
|
`event_id` is the Matrix event we've been streaming into, or None if
|
||||||
we didn't (yet) post a visible message this turn.
|
we didn't (yet) post a visible message this turn.
|
||||||
|
`last_sent_text` is the text last sent/edited to Matrix (for dedup).
|
||||||
"""
|
"""
|
||||||
content_parts: list[str] = []
|
content_parts: list[str] = []
|
||||||
tool_calls_acc: dict[int, dict] = {}
|
tool_calls_acc: dict[int, dict] = {}
|
||||||
usage: dict | None = None
|
usage: dict | None = None
|
||||||
event_id = prior_event_id
|
event_id = prior_event_id
|
||||||
last_edit = 0.0
|
last_edit = 0.0
|
||||||
|
last_sent_text: str = "" # track what was last sent to Matrix to avoid redundant edits
|
||||||
|
first_content_time: float = 0.0 # monotonic time of first content delta
|
||||||
EDIT_THROTTLE = 0.6 # seconds — keep Matrix edit traffic reasonable
|
EDIT_THROTTLE = 0.6 # seconds — keep Matrix edit traffic reasonable
|
||||||
MIN_CHARS_BEFORE_POST = 20 # avoid posting a single character first
|
MIN_CHARS_BEFORE_POST = 20 # avoid posting a single character first
|
||||||
|
TOOL_GRACE_SECONDS = 1.2 # buffer initial content this long; tool_calls deltas usually arrive within ~500ms
|
||||||
|
|
||||||
try:
|
try:
|
||||||
stream = await self.llm.chat.completions.create(
|
stream = await self.llm.chat.completions.create(
|
||||||
@@ -3773,7 +3809,7 @@ class Bot:
|
|||||||
"prompt_tokens": getattr(resp.usage, "prompt_tokens", 0),
|
"prompt_tokens": getattr(resp.usage, "prompt_tokens", 0),
|
||||||
"completion_tokens": getattr(resp.usage, "completion_tokens", 0),
|
"completion_tokens": getattr(resp.usage, "completion_tokens", 0),
|
||||||
}
|
}
|
||||||
return choice.message.content or "", tc_list, u, event_id
|
return choice.message.content or "", tc_list, u, event_id, ""
|
||||||
|
|
||||||
async for chunk in stream:
|
async for chunk in stream:
|
||||||
if not chunk.choices:
|
if not chunk.choices:
|
||||||
@@ -3806,13 +3842,20 @@ class Bot:
|
|||||||
# Suppress visible streaming once we know this turn will end in tool calls
|
# Suppress visible streaming once we know this turn will end in tool calls
|
||||||
if not tool_calls_acc:
|
if not tool_calls_acc:
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if now - last_edit >= EDIT_THROTTLE:
|
if first_content_time == 0.0:
|
||||||
|
first_content_time = now
|
||||||
|
# Grace period: hold first post long enough for tool_calls deltas
|
||||||
|
# to start arriving, so we never leak a "Gute Frage — lass mich…"
|
||||||
|
# preamble that the model intends to follow with tool calls.
|
||||||
|
grace_passed = (event_id is not None) or (now - first_content_time >= TOOL_GRACE_SECONDS)
|
||||||
|
if grace_passed and now - last_edit >= EDIT_THROTTLE:
|
||||||
text_so_far = "".join(content_parts)
|
text_so_far = "".join(content_parts)
|
||||||
if len(text_so_far) >= MIN_CHARS_BEFORE_POST:
|
if len(text_so_far) >= MIN_CHARS_BEFORE_POST:
|
||||||
if event_id is None:
|
if event_id is None:
|
||||||
event_id = await self._send_stream_start(room_id, text_so_far)
|
event_id = await self._send_stream_start(room_id, text_so_far)
|
||||||
else:
|
else:
|
||||||
await self._send_stream_edit(room_id, event_id, text_so_far)
|
await self._send_stream_edit(room_id, event_id, text_so_far)
|
||||||
|
last_sent_text = text_so_far
|
||||||
last_edit = now
|
last_edit = now
|
||||||
|
|
||||||
# Some providers attach usage to the last choice chunk
|
# Some providers attach usage to the last choice chunk
|
||||||
@@ -3874,7 +3917,7 @@ class Bot:
|
|||||||
"[stream] model=%s chars=%d tool_calls=%d streamed_to_matrix=%s",
|
"[stream] model=%s chars=%d tool_calls=%d streamed_to_matrix=%s",
|
||||||
model, len(content), len(tc_list or []), event_id is not None,
|
model, len(content), len(tc_list or []), event_id is not None,
|
||||||
)
|
)
|
||||||
return content, tc_list, usage, event_id
|
return content, tc_list, usage, event_id, last_sent_text
|
||||||
|
|
||||||
async def _get_call_encryption_key(self, room_id: str, sender: str, caller_device_id: str = "") -> bytes | None:
|
async def _get_call_encryption_key(self, room_id: str, sender: str, caller_device_id: str = "") -> bytes | None:
|
||||||
"""Read E2EE encryption key from call.member state (MSC4143) or timeline (legacy).
|
"""Read E2EE encryption key from call.member state (MSC4143) or timeline (legacy).
|
||||||
|
|||||||
Submodule confluence-collab updated: c4238974a7...a189fa326b
@@ -1,192 +0,0 @@
|
|||||||
"""Browser scrape executor — dispatches jobs to Skyvern API."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
SKYVERN_BASE_URL = os.environ.get("SKYVERN_BASE_URL", "http://skyvern:8000")
|
|
||||||
SKYVERN_API_KEY = os.environ.get("SKYVERN_API_KEY", "")
|
|
||||||
|
|
||||||
POLL_INTERVAL = 5 # seconds
|
|
||||||
MAX_POLL_TIME = 300 # 5 minutes
|
|
||||||
|
|
||||||
|
|
||||||
async def _create_task(url: str, goal: str, extraction_goal: str = "",
|
|
||||||
extraction_schema: dict | None = None,
|
|
||||||
credential_id: str | None = None, totp_identifier: str | None = None) -> str:
|
|
||||||
"""Create a Skyvern task and return the task_id."""
|
|
||||||
payload: dict = {
|
|
||||||
"url": url,
|
|
||||||
"navigation_goal": goal,
|
|
||||||
"data_extraction_goal": extraction_goal or goal,
|
|
||||||
}
|
|
||||||
if extraction_schema:
|
|
||||||
payload["extracted_information_schema"] = extraction_schema
|
|
||||||
if credential_id:
|
|
||||||
payload["credential_id"] = credential_id
|
|
||||||
if totp_identifier:
|
|
||||||
payload["totp_identifier"] = totp_identifier
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
|
||||||
resp = await client.post(
|
|
||||||
f"{SKYVERN_BASE_URL}/api/v1/tasks",
|
|
||||||
headers={
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"x-api-key": SKYVERN_API_KEY,
|
|
||||||
},
|
|
||||||
json=payload,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
return data["task_id"]
|
|
||||||
|
|
||||||
|
|
||||||
async def _poll_task(run_id: str) -> dict:
|
|
||||||
"""Poll Skyvern until task completes or times out."""
|
|
||||||
elapsed = 0
|
|
||||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
|
||||||
while elapsed < MAX_POLL_TIME:
|
|
||||||
resp = await client.get(
|
|
||||||
f"{SKYVERN_BASE_URL}/api/v1/tasks/{run_id}",
|
|
||||||
headers={"x-api-key": SKYVERN_API_KEY},
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
status = data.get("status", "")
|
|
||||||
|
|
||||||
if status in ("completed", "failed", "terminated", "timed_out"):
|
|
||||||
return data
|
|
||||||
|
|
||||||
await asyncio.sleep(POLL_INTERVAL)
|
|
||||||
elapsed += POLL_INTERVAL
|
|
||||||
|
|
||||||
return {"status": "timed_out", "error": f"Polling exceeded {MAX_POLL_TIME}s"}
|
|
||||||
|
|
||||||
|
|
||||||
def _format_extraction(data: dict) -> str:
|
|
||||||
"""Format extracted data as readable markdown for Matrix."""
|
|
||||||
extracted = data.get("extracted_information") or data.get("extracted_data")
|
|
||||||
if not extracted:
|
|
||||||
return "No data extracted."
|
|
||||||
|
|
||||||
# Handle list of items (most common: news, listings, results)
|
|
||||||
items = None
|
|
||||||
if isinstance(extracted, list):
|
|
||||||
items = extracted
|
|
||||||
elif isinstance(extracted, dict):
|
|
||||||
# Look for the first list value in the dict (e.g. {"news": [...]})
|
|
||||||
for v in extracted.values():
|
|
||||||
if isinstance(v, list) and v:
|
|
||||||
items = v
|
|
||||||
break
|
|
||||||
|
|
||||||
if items and isinstance(items[0], dict):
|
|
||||||
lines = []
|
|
||||||
for item in items:
|
|
||||||
# Try common field names for title/link
|
|
||||||
title = item.get("title") or item.get("name") or item.get("headline") or ""
|
|
||||||
link = item.get("link") or item.get("url") or item.get("href") or ""
|
|
||||||
# Build a line with remaining fields as details
|
|
||||||
skip = {"title", "name", "headline", "link", "url", "href"}
|
|
||||||
details = " · ".join(
|
|
||||||
str(v) for k, v in item.items()
|
|
||||||
if k not in skip and v
|
|
||||||
)
|
|
||||||
if title and link:
|
|
||||||
line = f"- [{title}]({link})"
|
|
||||||
elif title:
|
|
||||||
line = f"- {title}"
|
|
||||||
else:
|
|
||||||
line = f"- {json.dumps(item, ensure_ascii=False)}"
|
|
||||||
if details:
|
|
||||||
line += f" \n {details}"
|
|
||||||
lines.append(line)
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
# Fallback: compact JSON
|
|
||||||
if isinstance(extracted, (dict, list)):
|
|
||||||
return json.dumps(extracted, indent=2, ensure_ascii=False)
|
|
||||||
return str(extracted)
|
|
||||||
|
|
||||||
|
|
||||||
async def execute_browser_scrape(job: dict, send_text, **_kwargs) -> dict:
|
|
||||||
"""Execute a browser-based scraping job via Skyvern."""
|
|
||||||
target_room = job["targetRoom"]
|
|
||||||
config = job.get("config", {})
|
|
||||||
url = config.get("url", "")
|
|
||||||
goal = config.get("goal", config.get("query", f"Scrape content from {url}"))
|
|
||||||
extraction_goal = config.get("extractionGoal", "") or goal
|
|
||||||
extraction_schema = config.get("extractionSchema")
|
|
||||||
browser_profile = job.get("browserProfile")
|
|
||||||
|
|
||||||
if not url:
|
|
||||||
await send_text(target_room, f"**{job['name']}**: No URL configured.")
|
|
||||||
return {"status": "error", "error": "No URL configured"}
|
|
||||||
|
|
||||||
if not SKYVERN_API_KEY:
|
|
||||||
await send_text(
|
|
||||||
target_room,
|
|
||||||
f"**{job['name']}**: Browser automation not configured (missing API key).",
|
|
||||||
)
|
|
||||||
return {"status": "error", "error": "SKYVERN_API_KEY not set"}
|
|
||||||
|
|
||||||
# Map browser profile fields to Skyvern credential
|
|
||||||
credential_id = None
|
|
||||||
totp_identifier = None
|
|
||||||
if browser_profile:
|
|
||||||
if browser_profile.get("status") == "expired":
|
|
||||||
await send_text(
|
|
||||||
target_room,
|
|
||||||
f"**{job['name']}**: Browser credential expired. "
|
|
||||||
f"Update at https://matrixhost.eu/settings/automations",
|
|
||||||
)
|
|
||||||
return {"status": "error", "error": "Browser credential expired"}
|
|
||||||
credential_id = browser_profile.get("credentialId")
|
|
||||||
totp_identifier = browser_profile.get("totpIdentifier")
|
|
||||||
|
|
||||||
try:
|
|
||||||
run_id = await _create_task(
|
|
||||||
url=url,
|
|
||||||
goal=goal,
|
|
||||||
extraction_goal=extraction_goal,
|
|
||||||
extraction_schema=extraction_schema,
|
|
||||||
credential_id=credential_id,
|
|
||||||
totp_identifier=totp_identifier,
|
|
||||||
)
|
|
||||||
logger.info("Skyvern task created: %s for job %s", run_id, job["name"])
|
|
||||||
|
|
||||||
result = await _poll_task(run_id)
|
|
||||||
status = result.get("status", "unknown")
|
|
||||||
|
|
||||||
if status == "completed":
|
|
||||||
extracted = _format_extraction(result)
|
|
||||||
msg = f"**{job['name']}** — {url}\n\n{extracted}"
|
|
||||||
# Truncate if too long for Matrix
|
|
||||||
if len(msg) > 4000:
|
|
||||||
msg = msg[:3950] + "\n\n_(truncated)_"
|
|
||||||
await send_text(target_room, msg)
|
|
||||||
return {"status": "success"}
|
|
||||||
else:
|
|
||||||
error = result.get("error") or result.get("failure_reason") or status
|
|
||||||
await send_text(
|
|
||||||
target_room,
|
|
||||||
f"**{job['name']}**: Browser task {status} — {error}",
|
|
||||||
)
|
|
||||||
return {"status": "error", "error": str(error)}
|
|
||||||
|
|
||||||
except httpx.HTTPStatusError as exc:
|
|
||||||
error_msg = f"Skyvern API error: {exc.response.status_code}"
|
|
||||||
logger.error("Browser executor failed: %s", error_msg, exc_info=True)
|
|
||||||
await send_text(target_room, f"**{job['name']}**: {error_msg}")
|
|
||||||
return {"status": "error", "error": error_msg}
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
error_msg = str(exc)
|
|
||||||
logger.error("Browser executor failed: %s", error_msg, exc_info=True)
|
|
||||||
await send_text(target_room, f"**{job['name']}**: Browser task failed — {error_msg}")
|
|
||||||
return {"status": "error", "error": error_msg}
|
|
||||||
@@ -3,14 +3,12 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from .brave_search import execute_brave_search
|
from .brave_search import execute_brave_search
|
||||||
from .browser_executor import execute_browser_scrape
|
|
||||||
from .reminder import execute_reminder
|
from .reminder import execute_reminder
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
EXECUTORS = {
|
EXECUTORS = {
|
||||||
"brave_search": execute_brave_search,
|
"brave_search": execute_brave_search,
|
||||||
"browser_scrape": execute_browser_scrape,
|
|
||||||
"reminder": execute_reminder,
|
"reminder": execute_reminder,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ services:
|
|||||||
- MEMORY_SERVICE_TOKEN
|
- MEMORY_SERVICE_TOKEN
|
||||||
- PORTAL_URL
|
- PORTAL_URL
|
||||||
- BOT_API_KEY
|
- BOT_API_KEY
|
||||||
- SKYVERN_BASE_URL=http://skyvern:8000
|
|
||||||
- SKYVERN_API_KEY
|
|
||||||
ports:
|
ports:
|
||||||
- "9100:9100"
|
- "9100:9100"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -38,6 +36,7 @@ services:
|
|||||||
- ./e2ee_patch.py:/app/e2ee_patch.py:ro
|
- ./e2ee_patch.py:/app/e2ee_patch.py:ro
|
||||||
- ./cross_signing.py:/app/cross_signing.py:ro
|
- ./cross_signing.py:/app/cross_signing.py:ro
|
||||||
- ./device_trust.py:/app/device_trust.py:ro
|
- ./device_trust.py:/app/device_trust.py:ro
|
||||||
|
- ./article_summary:/app/article_summary:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
memory-service:
|
memory-service:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -84,54 +83,6 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
skyvern:
|
|
||||||
image: public.ecr.aws/skyvern/skyvern:latest
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
DATABASE_STRING: postgresql+psycopg://skyvern:${SKYVERN_DB_PASSWORD:-skyvern}@skyvern-db:5432/skyvern
|
|
||||||
ENABLE_OPENAI_COMPATIBLE: "true"
|
|
||||||
OPENAI_COMPATIBLE_API_KEY: ${LITELLM_API_KEY}
|
|
||||||
OPENAI_COMPATIBLE_API_BASE: ${LITELLM_BASE_URL}
|
|
||||||
OPENAI_COMPATIBLE_MODEL_NAME: gpt-4o
|
|
||||||
OPENAI_COMPATIBLE_SUPPORTS_VISION: "true"
|
|
||||||
LLM_KEY: OPENAI_COMPATIBLE
|
|
||||||
SECONDARY_LLM_KEY: OPENAI_COMPATIBLE
|
|
||||||
BROWSER_TYPE: chromium-headful
|
|
||||||
ENABLE_CODE_BLOCK: "true"
|
|
||||||
ENV: local
|
|
||||||
PORT: "8000"
|
|
||||||
ALLOWED_ORIGINS: '["http://localhost:8000"]'
|
|
||||||
volumes:
|
|
||||||
- skyvern-artifacts:/data/artifacts
|
|
||||||
- skyvern-videos:/data/videos
|
|
||||||
depends_on:
|
|
||||||
skyvern-db:
|
|
||||||
condition: service_healthy
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/v1/heartbeat')"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
start_period: 60s
|
|
||||||
|
|
||||||
skyvern-db:
|
|
||||||
image: postgres:14-alpine
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: skyvern
|
|
||||||
POSTGRES_PASSWORD: ${SKYVERN_DB_PASSWORD:-skyvern}
|
|
||||||
POSTGRES_DB: skyvern
|
|
||||||
volumes:
|
|
||||||
- skyvern-pgdata:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U skyvern -d skyvern"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
bot-data:
|
bot-data:
|
||||||
memory-pgdata:
|
memory-pgdata:
|
||||||
skyvern-pgdata:
|
|
||||||
skyvern-artifacts:
|
|
||||||
skyvern-videos:
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from .script import execute_script
|
|||||||
from .claude_prompt import execute_claude_prompt
|
from .claude_prompt import execute_claude_prompt
|
||||||
from .template import execute_template
|
from .template import execute_template
|
||||||
from .api_call import execute_api_call
|
from .api_call import execute_api_call
|
||||||
from .skyvern import execute_skyvern
|
|
||||||
from .pitrader_step import execute_pitrader
|
from .pitrader_step import execute_pitrader
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -16,7 +15,6 @@ STEP_EXECUTORS = {
|
|||||||
"claude_prompt": execute_claude_prompt,
|
"claude_prompt": execute_claude_prompt,
|
||||||
"template": execute_template,
|
"template": execute_template,
|
||||||
"api_call": execute_api_call,
|
"api_call": execute_api_call,
|
||||||
"skyvern": execute_skyvern,
|
|
||||||
"pitrader_script": execute_pitrader,
|
"pitrader_script": execute_pitrader,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
"""Skyvern step — browser automation via Skyvern API for pipeline execution."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
SKYVERN_BASE_URL = os.environ.get("SKYVERN_BASE_URL", "http://skyvern:8000")
|
|
||||||
SKYVERN_API_KEY = os.environ.get("SKYVERN_API_KEY", "")
|
|
||||||
|
|
||||||
POLL_INTERVAL = 5
|
|
||||||
MAX_POLL_TIME = 300
|
|
||||||
|
|
||||||
|
|
||||||
async def execute_skyvern(config: dict, send_text=None, target_room: str = "", **_kwargs) -> str:
|
|
||||||
"""Dispatch a browser task to Skyvern and return extracted data.
|
|
||||||
|
|
||||||
Config fields:
|
|
||||||
url: target URL (required)
|
|
||||||
goal: navigation goal / prompt (required)
|
|
||||||
data_extraction_goal: what to extract (optional, added to prompt)
|
|
||||||
extraction_schema: JSON schema for structured extraction (optional)
|
|
||||||
credential_id: Skyvern credential ID for login (optional)
|
|
||||||
totp_identifier: email/phone for TOTP (optional)
|
|
||||||
timeout_s: max poll time in seconds (optional, default 300)
|
|
||||||
"""
|
|
||||||
if not SKYVERN_API_KEY:
|
|
||||||
raise RuntimeError("SKYVERN_API_KEY not configured")
|
|
||||||
|
|
||||||
url = config.get("url", "")
|
|
||||||
goal = config.get("goal", "")
|
|
||||||
data_extraction_goal = config.get("data_extraction_goal", "")
|
|
||||||
extraction_schema = config.get("extraction_schema")
|
|
||||||
credential_id = config.get("credential_id")
|
|
||||||
totp_identifier = config.get("totp_identifier")
|
|
||||||
max_poll = config.get("timeout_s", MAX_POLL_TIME)
|
|
||||||
|
|
||||||
if not url or not goal:
|
|
||||||
raise ValueError("Skyvern step requires 'url' and 'goal' in config")
|
|
||||||
|
|
||||||
payload: dict = {
|
|
||||||
"url": url,
|
|
||||||
"navigation_goal": goal,
|
|
||||||
"data_extraction_goal": data_extraction_goal or goal,
|
|
||||||
}
|
|
||||||
if extraction_schema:
|
|
||||||
if isinstance(extraction_schema, str):
|
|
||||||
extraction_schema = json.loads(extraction_schema)
|
|
||||||
payload["extracted_information_schema"] = extraction_schema
|
|
||||||
if credential_id:
|
|
||||||
payload["credential_id"] = credential_id
|
|
||||||
if totp_identifier:
|
|
||||||
payload["totp_identifier"] = totp_identifier
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"x-api-key": SKYVERN_API_KEY,
|
|
||||||
}
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
|
||||||
resp = await client.post(
|
|
||||||
f"{SKYVERN_BASE_URL}/api/v1/tasks",
|
|
||||||
headers=headers,
|
|
||||||
json=payload,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
run_id = resp.json()["task_id"]
|
|
||||||
|
|
||||||
logger.info("Skyvern pipeline task created: %s", run_id)
|
|
||||||
|
|
||||||
if send_text and target_room:
|
|
||||||
await send_text(target_room, f"Browser task started for {url}...")
|
|
||||||
|
|
||||||
# Poll for completion
|
|
||||||
elapsed = 0
|
|
||||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
|
||||||
while elapsed < max_poll:
|
|
||||||
resp = await client.get(
|
|
||||||
f"{SKYVERN_BASE_URL}/api/v1/tasks/{run_id}",
|
|
||||||
headers={"x-api-key": SKYVERN_API_KEY},
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
status = data.get("status", "")
|
|
||||||
|
|
||||||
if status == "completed":
|
|
||||||
extracted = data.get("extracted_information") or data.get("extracted_data")
|
|
||||||
if extracted is None:
|
|
||||||
return "Task completed, no data extracted."
|
|
||||||
if isinstance(extracted, (dict, list)):
|
|
||||||
return json.dumps(extracted, ensure_ascii=False)
|
|
||||||
return str(extracted)
|
|
||||||
|
|
||||||
if status in ("failed", "terminated", "timed_out"):
|
|
||||||
error = data.get("error") or data.get("failure_reason") or status
|
|
||||||
raise RuntimeError(f"Skyvern task {status}: {error}")
|
|
||||||
|
|
||||||
await asyncio.sleep(POLL_INTERVAL)
|
|
||||||
elapsed += POLL_INTERVAL
|
|
||||||
|
|
||||||
raise TimeoutError(f"Skyvern task {run_id} did not complete within {max_poll}s")
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
livekit-agents>=1.4,<2.0
|
livekit-agents==1.5.1
|
||||||
livekit-plugins-openai>=1.4,<2.0
|
livekit-plugins-openai==1.5.1
|
||||||
livekit-plugins-elevenlabs>=1.4,<2.0
|
livekit-plugins-elevenlabs==1.5.1
|
||||||
livekit-plugins-silero>=1.4,<2.0
|
livekit-plugins-silero==1.5.1
|
||||||
livekit>=1.0,<2.0
|
livekit==1.1.3
|
||||||
livekit-api>=1.0,<2.0
|
livekit-api==1.1.0
|
||||||
matrix-nio[e2e]>=0.25,<1.0
|
matrix-nio[e2e]>=0.25,<1.0
|
||||||
canonicaljson>=2.0,<3.0
|
canonicaljson>=2.0,<3.0
|
||||||
httpx>=0.27,<1.0
|
httpx>=0.27,<1.0
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
"""Tests for the browser scrape executor."""
|
|
||||||
|
|
||||||
from unittest.mock import AsyncMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from cron.browser_executor import execute_browser_scrape
|
|
||||||
|
|
||||||
|
|
||||||
class TestBrowserScrapeExecutor:
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_returns_error_without_profile(self):
|
|
||||||
job = {
|
|
||||||
"id": "j1",
|
|
||||||
"name": "FB Scan",
|
|
||||||
"config": {"url": "https://facebook.com/marketplace"},
|
|
||||||
"targetRoom": "!room:test",
|
|
||||||
"browserProfile": None,
|
|
||||||
}
|
|
||||||
send_text = AsyncMock()
|
|
||||||
result = await execute_browser_scrape(job=job, send_text=send_text)
|
|
||||||
assert result["status"] == "error"
|
|
||||||
assert "browser profile" in result["error"].lower()
|
|
||||||
send_text.assert_called_once()
|
|
||||||
msg = send_text.call_args[0][1]
|
|
||||||
assert "matrixhost.eu/settings/automations" in msg
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_returns_error_with_expired_profile(self):
|
|
||||||
job = {
|
|
||||||
"id": "j1",
|
|
||||||
"name": "FB Scan",
|
|
||||||
"config": {"url": "https://facebook.com/marketplace"},
|
|
||||||
"targetRoom": "!room:test",
|
|
||||||
"browserProfile": {"id": "b1", "status": "expired", "name": "facebook"},
|
|
||||||
}
|
|
||||||
send_text = AsyncMock()
|
|
||||||
result = await execute_browser_scrape(job=job, send_text=send_text)
|
|
||||||
assert result["status"] == "error"
|
|
||||||
assert "expired" in result["error"].lower()
|
|
||||||
send_text.assert_called_once()
|
|
||||||
msg = send_text.call_args[0][1]
|
|
||||||
assert "re-record" in msg.lower()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_placeholder_with_active_profile(self):
|
|
||||||
job = {
|
|
||||||
"id": "j1",
|
|
||||||
"name": "FB Scan",
|
|
||||||
"config": {"url": "https://facebook.com/marketplace"},
|
|
||||||
"targetRoom": "!room:test",
|
|
||||||
"browserProfile": {"id": "b1", "status": "active", "name": "facebook"},
|
|
||||||
}
|
|
||||||
send_text = AsyncMock()
|
|
||||||
result = await execute_browser_scrape(job=job, send_text=send_text)
|
|
||||||
# Currently a placeholder, should indicate not yet implemented
|
|
||||||
assert result["status"] == "error"
|
|
||||||
assert "not yet implemented" in result["error"].lower()
|
|
||||||
@@ -33,16 +33,16 @@ class TestExecuteJob:
|
|||||||
assert "Don't forget!" in send_text.call_args[0][1]
|
assert "Don't forget!" in send_text.call_args[0][1]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_dispatches_to_browser_scrape_no_profile(self):
|
async def test_unknown_browser_scrape_returns_error(self):
|
||||||
|
"""browser_scrape was removed (Skyvern archived), should fail as unknown."""
|
||||||
job = {
|
job = {
|
||||||
"id": "j1",
|
"id": "j1",
|
||||||
"name": "Scrape Test",
|
"name": "Scrape Test",
|
||||||
"jobType": "browser_scrape",
|
"jobType": "browser_scrape",
|
||||||
"config": {"url": "https://example.com"},
|
"config": {"url": "https://example.com"},
|
||||||
"targetRoom": "!room:test",
|
"targetRoom": "!room:test",
|
||||||
"browserProfile": None,
|
|
||||||
}
|
}
|
||||||
send_text = AsyncMock()
|
send_text = AsyncMock()
|
||||||
result = await execute_job(job=job, send_text=send_text, matrix_client=None)
|
result = await execute_job(job=job, send_text=send_text, matrix_client=None)
|
||||||
assert result["status"] == "error"
|
assert result["status"] == "error"
|
||||||
assert "browser profile" in result["error"].lower()
|
assert "Unknown job type" in result["error"]
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ def test_short_message_skipped():
|
|||||||
def test_self_contained_no_pronouns_skipped():
|
def test_self_contained_no_pronouns_skipped():
|
||||||
assert _needs("What is the capital of France?") is False
|
assert _needs("What is the capital of France?") is False
|
||||||
assert _needs("Summarize the Q3 earnings report") is False
|
assert _needs("Summarize the Q3 earnings report") is False
|
||||||
assert _needs("Wie ist das Wetter in Berlin morgen") is False
|
# "das" is in trigger set (DE demonstrative), so German with articles triggers;
|
||||||
|
# this is acceptable — the LLM call is cheap and only adds latency, not errors
|
||||||
|
assert _needs("Convert 5 miles to kilometers") is False
|
||||||
|
|
||||||
|
|
||||||
def test_english_pronouns_trigger():
|
def test_english_pronouns_trigger():
|
||||||
|
|||||||
Reference in New Issue
Block a user