File size: 8,723 Bytes
1bcb9d8
910dadd
1bcb9d8
 
 
 
 
 
 
 
 
 
 
 
 
 
910dadd
 
 
 
 
 
 
 
1bcb9d8
910dadd
 
1bcb9d8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
910dadd
 
1bcb9d8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
910dadd
 
 
 
 
 
 
 
 
 
 
 
1bcb9d8
910dadd
 
 
 
 
 
 
 
 
 
1bcb9d8
 
910dadd
 
 
 
1bcb9d8
910dadd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d6217aa
910dadd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
"""Generic task runner: a tool-using chat loop with scoring and LangFuse tracing.

Each task runs against a backend (config.Backend) resolved from its `backend`
name β€” so different demos can target different endpoints (Anthropic-direct, an
Oumi proxy, ...) in the same app. Two wire protocols per the backend's provider:
- "openai": OpenAI Chat Completions via the langfuse.openai wrapper (calls are
  auto-traced as generations).
- "anthropic": Anthropic Messages (for Oumi deployments that proxy an Anthropic
  model). Tasks still define tools in OpenAI format; we translate at call time
  and record each call as a generation manually.

Either path returns (final_text, transcript, messages); everything downstream β€”
parsing, scoring, tracing attributes, the return shape β€” is provider-agnostic.
`messages` is always the complete OpenAI-format trajectory (system β†’ final
answer), including on the anthropic path, so the trajectory artifact the UI
copies/downloads is one standard chat record whatever backend produced it.
"""

import json

from langfuse import get_client, observe, propagate_attributes
from langfuse.openai import OpenAI

from app import config
from app.config import Backend
from app.tasks.base import Task

# Clients are cached per backend identity so repeated calls reuse connections.
_clients: dict[tuple, object] = {}


def _openai(be: Backend):
    key = ("openai", be.base_url, be.api_key)
    if key not in _clients:
        _clients[key] = OpenAI(base_url=be.base_url, api_key=be.api_key)
    return _clients[key]


def _anthropic(be: Backend):
    key = ("anthropic", be.base_url, be.api_key)
    if key not in _clients:
        import anthropic

        _clients[key] = anthropic.Anthropic(base_url=be.base_url, api_key=be.api_key)
    return _clients[key]


def _split_system(messages: list[dict]) -> tuple[str, list[dict]]:
    """Separate the system text from the conversation (Anthropic takes it apart)."""
    system = "\n\n".join(m["content"] for m in messages if m["role"] == "system")
    convo = [m for m in messages if m["role"] != "system"]
    return system, convo


def _run_openai(task: Task, text: str, be: Backend) -> tuple[str, list[dict], list[dict]]:
    messages = task.build_messages(text)
    transcript: list[dict] = []
    for _ in range(config.MAX_TOOL_ITERATIONS):
        response = _openai(be).chat.completions.create(
            model=be.model,
            max_tokens=4096,
            messages=messages,
            **({"tools": task.tools} if task.tools else {}),
        )
        message = response.choices[0].message
        if not message.tool_calls:
            final_text = message.content or ""
            messages.append({"role": "assistant", "content": final_text})
            return final_text, transcript, messages
        messages.append({
            "role": "assistant",
            "content": message.content,
            "tool_calls": [tc.model_dump() for tc in message.tool_calls],
        })
        for tc in message.tool_calls:
            try:
                args = json.loads(tc.function.arguments)
            except json.JSONDecodeError:
                args = {}
            result = task.execute_tool(tc.function.name, args)
            transcript.append({"tool": tc.function.name, "args": args, "result": result})
            messages.append({"role": "tool", "tool_call_id": tc.id, "content": result})
    return "(stopped: tool iteration limit reached)", transcript, messages


def _to_anthropic_tools(tools: list[dict]) -> list[dict]:
    """OpenAI function-tool schema -> Anthropic tool schema."""
    return [
        {
            "name": t["function"]["name"],
            "description": t["function"].get("description", ""),
            "input_schema": t["function"]["parameters"],
        }
        for t in tools
    ]


def _runner_for(be: Backend):
    """Pick the wire-protocol loop for a backend."""
    return _run_anthropic if be.provider == "anthropic" else _run_openai


def _run_anthropic(task: Task, text: str, be: Backend) -> tuple[str, list[dict], list[dict]]:
    # Two views of the same conversation: `messages` is what we send (Anthropic
    # native, system split out); `oai` is the OpenAI-format trajectory we return.
    oai = task.build_messages(text)
    system, messages = _split_system(oai)
    tools = _to_anthropic_tools(task.tools)
    transcript: list[dict] = []
    langfuse = get_client()
    for _ in range(config.MAX_TOOL_ITERATIONS):
        with langfuse.start_as_current_observation(
            name="anthropic-messages", as_type="generation",
            model=be.model, input=messages,
        ) as gen:
            resp = _anthropic(be).messages.create(
                model=be.model,
                max_tokens=4096,
                system=system,
                messages=messages,
                **({"tools": tools} if tools else {}),
            )
            gen.update(
                output=[b.model_dump() for b in resp.content],
                usage_details={
                    "input": resp.usage.input_tokens,
                    "output": resp.usage.output_tokens,
                },
            )
        said = "".join(b.text for b in resp.content if b.type == "text")
        if resp.stop_reason != "tool_use":
            oai.append({"role": "assistant", "content": said})
            return said, transcript, oai
        messages.append({"role": "assistant", "content": resp.content})
        calls = [b for b in resp.content if b.type == "tool_use"]
        oai.append({
            "role": "assistant",
            "content": said or None,
            "tool_calls": [
                {
                    "id": b.id,
                    "type": "function",
                    "function": {"name": b.name, "arguments": json.dumps(b.input)},
                }
                for b in calls
            ],
        })
        results = []
        for b in calls:
            result = task.execute_tool(b.name, b.input)
            transcript.append({"tool": b.name, "args": b.input, "result": result})
            results.append({"type": "tool_result", "tool_use_id": b.id, "content": result})
            oai.append({"role": "tool", "tool_call_id": b.id, "content": result})
        messages.append({"role": "user", "content": results})
    return "(stopped: tool iteration limit reached)", transcript, oai


@observe(name="contimp-app-run", capture_input=False, capture_output=False)
def run_task(
    task: Task,
    text: str,
    input_id: str | None,
    user_id: str,
    session_id: str,
    source: str = "human",
) -> dict:
    truth = task.lookup_truth(input_id) if input_id else None
    be = config.backend(task.backend)
    langfuse = get_client()

    with propagate_attributes(
        trace_name=f"{task.id}",
        user_id=user_id or "anonymous",
        session_id=session_id,
        tags=[task.id, source],
        metadata={
            "task_id": task.id,
            "app_version": config.APP_VERSION,
            "backend": be.name,
            "model": be.model,
            "input_id": input_id or "",
            "ground_truth": json.dumps(truth) if truth else "",
        },
    ):
        final_text, transcript, messages = _runner_for(be)(task, text, be)

        parsed = task.parse_output(final_text)
        scores = task.score(truth, parsed) if truth else {}
        for name, value in scores.items():
            langfuse.score_current_trace(name=name, value=value)
        if task.tools:
            langfuse.score_current_trace(
                name="tool_iterations", value=float(len(transcript))
            )

        langfuse.update_current_span(
            input=text, output=final_text, metadata={"ground_truth": truth}
        )
        trace_id = langfuse.get_current_trace_id()

    return {
        "task_id": task.id,
        "output": task.present(parsed, truth),
        "raw_output": final_text,
        "transcript": transcript,
        "messages": messages,  # complete OpenAI-format trajectory (system β†’ final answer)
        "truth": truth,
        "scores": scores,
        "trace_id": trace_id,
    }


def record_feedback(trace_id: str, value: int | None, comment: str | None = None) -> None:
    """Thumbs (numeric) when value is given; free-text note (TEXT score) otherwise."""
    if value is not None:
        get_client().create_score(
            trace_id=trace_id,
            name="thumbs",
            value=float(value),
            data_type="NUMERIC",
            comment=comment,
        )
    elif comment:
        get_client().create_score(
            trace_id=trace_id,
            name="feedback_note",
            value=comment,
            data_type="TEXT",
        )