Files

104 lines
3.7 KiB
Python

# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# Portions of this file are derived from mobile-use (https://github.com/minitap-ai/mobile-use)
# Copyright 2025-2026 Minitap, Inc. Licensed under the Apache License 2.0.
from pathlib import Path
from jinja2 import Template
from langchain_core.messages import HumanMessage, SystemMessage
from artemis.context import ArtemisContext
from artemis.data_engine.trace import trace
from artemis.services.llm import get_llm, invoke_llm_with_timeout_message, with_fallback
from artemis.utils.logger import get_logger
from pydantic import BaseModel, Field, model_validator
logger = get_logger(__name__)
class HopperError(RuntimeError):
"""Extraction failed; this says nothing about whether the requested data exists."""
class _CyFunctionDetectorMeta(type):
def __instancecheck__(self, instance):
name = type(instance).__name__
return (
name
in (
"cyfunction",
"cython_function_or_method",
"builtin_function_or_method",
)
or "cyfunction" in name.lower()
)
class CyFunctionDetector(metaclass=_CyFunctionDetectorMeta):
pass
class HopperOutput(BaseModel):
model_config = {"ignored_types": (CyFunctionDetector,)}
found: bool = Field(description="True if the requested data was found, False otherwise.")
output: str | None = Field(description="The extracted data if found, null otherwise.")
reason: str = Field(
description="A short explanation of what you looked for"
+ " and how you decided what to extract."
)
@model_validator(mode="after")
def validate_found_output(self):
if self.found and (not self.output or not self.output.strip()):
raise ValueError("A successful extraction must contain a nonempty output")
return self
@trace(type="agent", name="hopper")
async def hopper(
ctx: ArtemisContext,
request: str,
data: str,
use_fallback: bool = True,
) -> HopperOutput:
logger.info(f"Starting Hopper Agent (use_fallback={use_fallback})")
system_message = Template(
Path(__file__).parent.joinpath("hopper.md").read_text(encoding="utf-8")
).render()
messages = [
SystemMessage(content=system_message),
HumanMessage(content=f"{request}\nHere is the data you must dig:\n{data}"),
]
async def extract(model) -> HopperOutput:
response = await invoke_llm_with_timeout_message(model.ainvoke(messages))
return HopperOutput.model_validate(response)
try:
llm = get_llm(ctx=ctx, name="hopper", is_utils=True).with_structured_output(HopperOutput)
if use_fallback:
llm_fallback = get_llm(
ctx=ctx, name="hopper", is_utils=True, use_fallback=True
).with_structured_output(HopperOutput)
return await with_fallback(
main_call=lambda: extract(llm),
fallback_call=lambda: extract(llm_fallback),
)
else:
return await extract(llm)
except Exception as e:
logger.error(f"Hopper LLM invocation failed: {e}")
raise HopperError("模型未能完成数据提取,请查看模型请求诊断。") from e