luar 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- luar/__init__.py +16 -0
- luar/__main__.py +5 -0
- luar/app.py +256 -0
- luar/backends/__init__.py +17 -0
- luar/backends/base.py +29 -0
- luar/backends/laya_backend.py +96 -0
- luar/backends/lmstudio_backend.py +163 -0
- luar/cli.py +103 -0
- luar/engine.py +190 -0
- luar/questions.py +134 -0
- luar/report.py +95 -0
- luar/tables.py +87 -0
- luar-0.2.0.dist-info/METADATA +227 -0
- luar-0.2.0.dist-info/RECORD +18 -0
- luar-0.2.0.dist-info/WHEEL +5 -0
- luar-0.2.0.dist-info/entry_points.txt +2 -0
- luar-0.2.0.dist-info/licenses/LICENSE +21 -0
- luar-0.2.0.dist-info/top_level.txt +1 -0
luar/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""LUAR - Local Utility for Automated Reviews."""
|
|
2
|
+
from .engine import DEFAULT_THRESHOLD, Result, classify, run_file
|
|
3
|
+
from .questions import Question, QuestionError, load_questions, parse_questions
|
|
4
|
+
|
|
5
|
+
__version__ = "0.2.0"
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"DEFAULT_THRESHOLD",
|
|
9
|
+
"Question",
|
|
10
|
+
"QuestionError",
|
|
11
|
+
"Result",
|
|
12
|
+
"classify",
|
|
13
|
+
"load_questions",
|
|
14
|
+
"parse_questions",
|
|
15
|
+
"run_file",
|
|
16
|
+
]
|
luar/__main__.py
ADDED
luar/app.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Web interface (Gradio). Runs on 127.0.0.1 only: files never leave the machine."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import gradio as gr
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
from .backends.laya_backend import LayaBackend, pick_checkpoint
|
|
12
|
+
from .backends.lmstudio_backend import LMStudioBackend, LMStudioError
|
|
13
|
+
from .engine import DEFAULT_THRESHOLD, EXPECTED_PREFIX, REVIEW_COL, classify, save_result
|
|
14
|
+
from .questions import EXPERIMENTAL_TYPES, QuestionError, dump_questions, load_questions, parse_questions
|
|
15
|
+
from .report import render_summary
|
|
16
|
+
from .tables import read_table
|
|
17
|
+
|
|
18
|
+
EXAMPLES = Path(__file__).resolve().parents[2] / "examples"
|
|
19
|
+
QUESTION_HEADERS = ["id", "type", "question", "options"]
|
|
20
|
+
PREVIEW_ROWS = 8
|
|
21
|
+
_backends: dict[str, LayaBackend] = {} # loaded models, reused between runs
|
|
22
|
+
|
|
23
|
+
INTRO = """
|
|
24
|
+
# 🌙 LUAR
|
|
25
|
+
**Local Utility for Automated Reviews.** Drop a spreadsheet, say what you want to know about each row,
|
|
26
|
+
and get a copy with the answers plus a summary. Everything runs on this computer.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
QUESTION_HELP = """
|
|
30
|
+
**Question types:** `choice` picks one option · `noul` answers yes/no · `score` places the row on an
|
|
31
|
+
ordered scale (*experimental*: the least reliable type in our tests).
|
|
32
|
+
**Options:** separate with `;` and optionally add a description after `:`, e.g.
|
|
33
|
+
`delivery: shipping, delays; product: defects, quality`. Up to 20 options; `noul` takes none.
|
|
34
|
+
Add a column named `expected_<id>` to your file to measure accuracy on rows you already know.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _questions_to_rows(questions) -> list[list[str]]:
|
|
39
|
+
rows = []
|
|
40
|
+
for q in questions:
|
|
41
|
+
opts = "; ".join(f"{k}: {v}" if v else k for k, v in q.options.items())
|
|
42
|
+
rows.append([q.id, q.type, q.question, opts])
|
|
43
|
+
return rows
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _rows_to_questions(table) -> list:
|
|
47
|
+
df = table if isinstance(table, pd.DataFrame) else pd.DataFrame(table, columns=QUESTION_HEADERS)
|
|
48
|
+
data = []
|
|
49
|
+
for _, r in df.iterrows():
|
|
50
|
+
values = ["" if pd.isna(v) else str(v).strip() for v in r.tolist()[:4]]
|
|
51
|
+
if not any(values):
|
|
52
|
+
continue
|
|
53
|
+
qid, qtype, text, opts = values
|
|
54
|
+
data.append({"id": qid, "type": qtype.lower(), "question": text, "options": opts})
|
|
55
|
+
return parse_questions(data)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def on_file(file):
|
|
59
|
+
if file is None:
|
|
60
|
+
return gr.update(choices=[], value=[]), None, "", None
|
|
61
|
+
try:
|
|
62
|
+
df, info = read_table(file)
|
|
63
|
+
except Exception as e: # unreadable file: tell the user, keep the UI alive
|
|
64
|
+
raise gr.Error(f"Could not read this file: {e}") from e
|
|
65
|
+
# expected_* columns hold known answers: never offer them as model input
|
|
66
|
+
readable = [c for c in df.columns if not str(c).startswith(EXPECTED_PREFIX)]
|
|
67
|
+
# suggest the column with the longest text on average
|
|
68
|
+
lengths = {c: df[c].astype(str).str.len().mean() for c in readable}
|
|
69
|
+
best = max(lengths, key=lengths.get) if lengths else None
|
|
70
|
+
detail = f"separator `{info.sep}`, {info.encoding}" if info.kind == "csv" else f"sheet `{info.sheet}`"
|
|
71
|
+
status = f"**{Path(file).name}**: {len(df)} rows, {len(df.columns)} columns ({detail})."
|
|
72
|
+
return (
|
|
73
|
+
gr.update(choices=readable, value=[best] if best else []),
|
|
74
|
+
df.head(PREVIEW_ROWS),
|
|
75
|
+
status,
|
|
76
|
+
file,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def on_load_questions(file):
|
|
81
|
+
if file is None:
|
|
82
|
+
return gr.update()
|
|
83
|
+
try:
|
|
84
|
+
return _questions_to_rows(load_questions(file))
|
|
85
|
+
except (QuestionError, json.JSONDecodeError) as e:
|
|
86
|
+
raise gr.Error(str(e)) from e
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def on_save_questions(table):
|
|
90
|
+
try:
|
|
91
|
+
questions = _rows_to_questions(table)
|
|
92
|
+
except QuestionError as e:
|
|
93
|
+
raise gr.Error(str(e)) from e
|
|
94
|
+
path = Path(tempfile.mkdtemp(prefix="luar_")) / "questions.json"
|
|
95
|
+
path.write_text(dump_questions(questions), encoding="utf-8")
|
|
96
|
+
return path
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
EXAMPLE_SETS = {
|
|
100
|
+
"English reviews": ("reviews.csv", "reviews_questions.json"),
|
|
101
|
+
"English reviews: severity (score)": ("reviews.csv", "reviews_severity_question.json"),
|
|
102
|
+
"Avaliações em português": ("avaliacoes.csv", "avaliacoes_perguntas.json"),
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def on_example(name):
|
|
107
|
+
data, questions = EXAMPLE_SETS[name]
|
|
108
|
+
rows = _questions_to_rows(load_questions(EXAMPLES / questions))
|
|
109
|
+
return str(EXAMPLES / data), rows
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _backend(checkpoint: str, texts: list[str], questions) -> LayaBackend:
|
|
113
|
+
resolved = pick_checkpoint(texts, questions) if checkpoint == "auto" else checkpoint
|
|
114
|
+
if resolved not in _backends:
|
|
115
|
+
_backends[resolved] = LayaBackend(checkpoint=resolved)
|
|
116
|
+
backend = _backends[resolved]
|
|
117
|
+
backend.name = f"laya ({resolved}{', chosen automatically' if checkpoint == 'auto' else ''})"
|
|
118
|
+
return backend
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def on_engine(engine):
|
|
122
|
+
is_laya = engine == "Laya"
|
|
123
|
+
return gr.update(visible=is_laya), gr.update(visible=not is_laya), gr.update(visible=not is_laya)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def on_refresh_models():
|
|
127
|
+
"""List the LLMs LM Studio can serve (loaded or downloaded)."""
|
|
128
|
+
try:
|
|
129
|
+
models = LMStudioBackend().list_models()
|
|
130
|
+
except LMStudioError as e:
|
|
131
|
+
raise gr.Error(str(e)) from e
|
|
132
|
+
if not models:
|
|
133
|
+
raise gr.Error("LM Studio has no model available; load one (e.g. `lms load qwen3.5-4b`).")
|
|
134
|
+
return gr.update(choices=models, value=models[0])
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def on_run(file, columns, table, threshold, engine, checkpoint, lms_model, progress=gr.Progress()):
|
|
138
|
+
if not file:
|
|
139
|
+
raise gr.Error("Drop a CSV or XLSX file first.")
|
|
140
|
+
if not columns:
|
|
141
|
+
raise gr.Error("Choose at least one column to read.")
|
|
142
|
+
try:
|
|
143
|
+
questions = _rows_to_questions(table)
|
|
144
|
+
except QuestionError as e:
|
|
145
|
+
raise gr.Error(str(e)) from e
|
|
146
|
+
if any(q.type in EXPERIMENTAL_TYPES for q in questions):
|
|
147
|
+
gr.Warning("`score` questions are experimental: check those answers by hand.")
|
|
148
|
+
|
|
149
|
+
df, info = read_table(file)
|
|
150
|
+
texts = df[columns].astype(str).agg(" ".join, axis=1).tolist()
|
|
151
|
+
if engine == "Laya":
|
|
152
|
+
progress(0, desc="Loading the model (the first run downloads it)…")
|
|
153
|
+
backend = _backend(checkpoint, texts, questions)
|
|
154
|
+
else:
|
|
155
|
+
progress(0, desc="Asking LM Studio…")
|
|
156
|
+
backend = LMStudioBackend(model=lms_model or None)
|
|
157
|
+
try:
|
|
158
|
+
result = classify(
|
|
159
|
+
df, columns, questions, backend, threshold,
|
|
160
|
+
progress=lambda done, total: progress(done / total, desc=f"{done}/{total} rows"),
|
|
161
|
+
)
|
|
162
|
+
except (ValueError, LMStudioError) as e:
|
|
163
|
+
raise gr.Error(str(e)) from e
|
|
164
|
+
# uploads live in a temp folder; write the outputs to a fresh one
|
|
165
|
+
save_result(result, info, out_dir=tempfile.mkdtemp(prefix="luar_"))
|
|
166
|
+
flagged = (result.table[REVIEW_COL] == "yes").sum()
|
|
167
|
+
gr.Info(f"Done: {len(result.table)} rows, {flagged} to review.")
|
|
168
|
+
return result.table, render_summary(result), [str(result.table_path), str(result.summary_path)]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def build() -> gr.Blocks:
|
|
172
|
+
with gr.Blocks(title="LUAR") as demo:
|
|
173
|
+
gr.Markdown(INTRO)
|
|
174
|
+
source = gr.State()
|
|
175
|
+
with gr.Row():
|
|
176
|
+
with gr.Column(scale=1):
|
|
177
|
+
gr.Markdown("### 1. Spreadsheet")
|
|
178
|
+
upload = gr.File(label="CSV or XLSX", file_types=[".csv", ".xlsx", ".xlsm", ".txt"])
|
|
179
|
+
status = gr.Markdown()
|
|
180
|
+
columns = gr.CheckboxGroup(label="Column(s) the model should read", choices=[])
|
|
181
|
+
example = gr.Dropdown(
|
|
182
|
+
list(EXAMPLE_SETS), label="…or try an example", value=None,
|
|
183
|
+
visible=EXAMPLES.exists(), # examples ship with the repo, not the wheel
|
|
184
|
+
)
|
|
185
|
+
with gr.Column(scale=2):
|
|
186
|
+
gr.Markdown("### Preview")
|
|
187
|
+
preview = gr.Dataframe(interactive=False, max_height=260, wrap=True)
|
|
188
|
+
|
|
189
|
+
gr.Markdown("### 2. What do you want to know about each row?")
|
|
190
|
+
questions = gr.Dataframe(
|
|
191
|
+
headers=QUESTION_HEADERS,
|
|
192
|
+
datatype=["str", "str", "str", "str"],
|
|
193
|
+
value=[["", "choice", "", ""]],
|
|
194
|
+
interactive=True,
|
|
195
|
+
row_count=(1, "dynamic"),
|
|
196
|
+
column_count=(4, "fixed"),
|
|
197
|
+
wrap=True,
|
|
198
|
+
column_widths=["12%", "10%", "38%", "40%"],
|
|
199
|
+
)
|
|
200
|
+
gr.Markdown(QUESTION_HELP)
|
|
201
|
+
with gr.Row():
|
|
202
|
+
q_upload = gr.File(label="Load questions (.json)", file_types=[".json"], scale=1)
|
|
203
|
+
q_save = gr.Button("Save questions as .json", scale=0)
|
|
204
|
+
q_file = gr.File(label="Questions file", interactive=False, scale=1)
|
|
205
|
+
|
|
206
|
+
gr.Markdown("### 3. Run")
|
|
207
|
+
with gr.Row():
|
|
208
|
+
threshold = gr.Slider(
|
|
209
|
+
0.5, 0.95, value=DEFAULT_THRESHOLD, step=0.05, label="Confidence threshold",
|
|
210
|
+
info="Answers below it mark the row as needs_review",
|
|
211
|
+
)
|
|
212
|
+
engine = gr.Radio(
|
|
213
|
+
["Laya", "LM Studio"], value="Laya", label="Engine",
|
|
214
|
+
info="Laya: fast, built for decisions · LM Studio: a local LLM, slower, often more accurate",
|
|
215
|
+
)
|
|
216
|
+
checkpoint = gr.Dropdown(
|
|
217
|
+
["auto", "multilingual", "english"], value="auto", label="Laya model variant",
|
|
218
|
+
info="auto: English files use the English model, others the multilingual one",
|
|
219
|
+
)
|
|
220
|
+
lms_model = gr.Dropdown(
|
|
221
|
+
[], label="LM Studio model", visible=False, allow_custom_value=True,
|
|
222
|
+
info="Empty: the first model LM Studio has loaded",
|
|
223
|
+
)
|
|
224
|
+
lms_refresh = gr.Button("List LM Studio models", visible=False, scale=0)
|
|
225
|
+
run = gr.Button("Run", variant="primary")
|
|
226
|
+
|
|
227
|
+
gr.Markdown("### Result")
|
|
228
|
+
downloads = gr.File(label="Download (result copy + summary)", file_count="multiple", interactive=False)
|
|
229
|
+
with gr.Tabs():
|
|
230
|
+
with gr.Tab("Summary"):
|
|
231
|
+
summary = gr.Markdown()
|
|
232
|
+
with gr.Tab("Table"):
|
|
233
|
+
result = gr.Dataframe(interactive=False, max_height=420, wrap=True)
|
|
234
|
+
|
|
235
|
+
upload.change(on_file, upload, [columns, preview, status, source])
|
|
236
|
+
example.change(on_example, example, [upload, questions])
|
|
237
|
+
q_upload.change(on_load_questions, q_upload, questions)
|
|
238
|
+
q_save.click(on_save_questions, questions, q_file)
|
|
239
|
+
engine.change(on_engine, engine, [checkpoint, lms_model, lms_refresh])
|
|
240
|
+
lms_refresh.click(on_refresh_models, None, lms_model)
|
|
241
|
+
run.click(
|
|
242
|
+
on_run, [source, columns, questions, threshold, engine, checkpoint, lms_model],
|
|
243
|
+
[result, summary, downloads],
|
|
244
|
+
)
|
|
245
|
+
return demo
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def launch(port: int = 7860, share: bool = False, open_browser: bool = True) -> None:
|
|
249
|
+
build().launch(
|
|
250
|
+
server_name="127.0.0.1", server_port=port, share=share, inbrowser=open_browser,
|
|
251
|
+
theme=gr.themes.Soft(primary_hue="indigo", secondary_hue="slate"),
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
if __name__ == "__main__":
|
|
256
|
+
launch()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .base import Answer, Backend, Progress
|
|
2
|
+
from .laya_backend import LayaBackend
|
|
3
|
+
from .lmstudio_backend import LMStudioBackend, LMStudioError
|
|
4
|
+
|
|
5
|
+
ENGINES = ("laya", "lmstudio")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def make_backend(engine: str = "laya", **options) -> Backend:
|
|
9
|
+
"""Build an engine by name. Laya takes checkpoint/device; LM Studio takes model/base_url/api_key."""
|
|
10
|
+
if engine == "laya":
|
|
11
|
+
return LayaBackend(**options)
|
|
12
|
+
if engine == "lmstudio":
|
|
13
|
+
return LMStudioBackend(**options)
|
|
14
|
+
raise ValueError(f"Unknown engine {engine!r}; use one of {', '.join(ENGINES)}.")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
__all__ = ["Answer", "Backend", "ENGINES", "LayaBackend", "LMStudioBackend", "LMStudioError", "Progress", "make_backend"]
|
luar/backends/base.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""The "socket" every decision engine plugs into."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Callable, Protocol
|
|
6
|
+
|
|
7
|
+
from ..questions import Question
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Answer:
|
|
12
|
+
value: str | None # chosen label; "yes"/"no" for noul; None if the engine gave nothing
|
|
13
|
+
confidence: float | None # 0..1, how sure the engine is about `value`
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
Progress = Callable[[int, int], None]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Backend(Protocol):
|
|
20
|
+
name: str
|
|
21
|
+
|
|
22
|
+
def decide(
|
|
23
|
+
self,
|
|
24
|
+
texts: list[str],
|
|
25
|
+
questions: list[Question],
|
|
26
|
+
progress: Progress | None = None,
|
|
27
|
+
) -> list[dict[str, Answer]]:
|
|
28
|
+
"""Return one {question_id: Answer} per text, in the same order."""
|
|
29
|
+
...
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Laya backend: local Jev-style decision model (https://huggingface.co/convaiinnovations/laya)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from ..questions import Question
|
|
5
|
+
from .base import Answer, Progress
|
|
6
|
+
|
|
7
|
+
CHECKPOINTS = {
|
|
8
|
+
"auto": None, # picks english or multilingual from the file's language
|
|
9
|
+
"multilingual": "multilingual", # best tested option for non-English text
|
|
10
|
+
"english": None, # clearly better than multilingual on English text
|
|
11
|
+
"typed-decisions": "typed-decisions",
|
|
12
|
+
}
|
|
13
|
+
ENGLISH_SHARE = 0.8
|
|
14
|
+
LANGUAGE_SAMPLE = 200
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def pick_checkpoint(texts: list[str], questions: list[Question]) -> str:
|
|
18
|
+
""""english" when the questions and at least 80% of the (sampled) rows are English."""
|
|
19
|
+
import laya
|
|
20
|
+
|
|
21
|
+
sample = texts[:LANGUAGE_SAMPLE] + [q.question for q in questions]
|
|
22
|
+
decided = [d for d in map(laya.detect_language, sample) if not d["language_undecided"]]
|
|
23
|
+
if not decided:
|
|
24
|
+
return "multilingual"
|
|
25
|
+
english = sum(d["is_english"] for d in decided) / len(decided)
|
|
26
|
+
return "english" if english >= ENGLISH_SHARE else "multilingual"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def extract_answer(raw: dict | None, qtype: str) -> Answer:
|
|
30
|
+
"""Normalize one Laya answer (package 0.3.x format):
|
|
31
|
+
choice -> {"choice": "x", "answer_confidence": p}
|
|
32
|
+
score -> {"legend": {"0": "low", ...}, "probabilities": {"0": p, ...}}
|
|
33
|
+
noul -> {"noul": P(yes)}"""
|
|
34
|
+
if not isinstance(raw, dict):
|
|
35
|
+
return Answer(None, None)
|
|
36
|
+
if qtype == "noul":
|
|
37
|
+
p = float(raw["noul"])
|
|
38
|
+
return Answer("yes" if p >= 0.5 else "no", max(p, 1 - p))
|
|
39
|
+
if qtype == "score":
|
|
40
|
+
probs = raw["probabilities"]
|
|
41
|
+
k = max(probs, key=probs.get)
|
|
42
|
+
return Answer(raw["legend"][k], float(probs[k]))
|
|
43
|
+
conf = raw.get("answer_confidence", raw.get("confidence"))
|
|
44
|
+
return Answer(raw.get("choice"), None if conf is None else float(conf))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class LayaBackend:
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
checkpoint: str = "auto",
|
|
51
|
+
device: str | None = None,
|
|
52
|
+
repo: str = "convaiinnovations/laya",
|
|
53
|
+
batch_size: int = 16,
|
|
54
|
+
):
|
|
55
|
+
if checkpoint not in CHECKPOINTS:
|
|
56
|
+
raise ValueError(f"Unknown checkpoint {checkpoint!r}; use one of {', '.join(CHECKPOINTS)}.")
|
|
57
|
+
self.checkpoint = checkpoint
|
|
58
|
+
self.device = device
|
|
59
|
+
self.repo = repo
|
|
60
|
+
self.batch_size = batch_size
|
|
61
|
+
self.name = f"laya ({checkpoint})"
|
|
62
|
+
self._agent = None
|
|
63
|
+
|
|
64
|
+
def _load(self, texts: list[str], questions: list[Question]):
|
|
65
|
+
if self.checkpoint == "auto":
|
|
66
|
+
self.checkpoint = pick_checkpoint(texts, questions)
|
|
67
|
+
self.name = f"laya ({self.checkpoint}, chosen automatically)"
|
|
68
|
+
if self._agent is None:
|
|
69
|
+
import laya # heavy import (torch); only when actually needed
|
|
70
|
+
|
|
71
|
+
kwargs = {}
|
|
72
|
+
if self.device:
|
|
73
|
+
kwargs["device"] = self.device
|
|
74
|
+
if CHECKPOINTS[self.checkpoint]:
|
|
75
|
+
kwargs["subfolder"] = CHECKPOINTS[self.checkpoint]
|
|
76
|
+
self._agent = laya.load(self.repo, **kwargs)
|
|
77
|
+
return self._agent
|
|
78
|
+
|
|
79
|
+
def decide(
|
|
80
|
+
self,
|
|
81
|
+
texts: list[str],
|
|
82
|
+
questions: list[Question],
|
|
83
|
+
progress: Progress | None = None,
|
|
84
|
+
) -> list[dict[str, Answer]]:
|
|
85
|
+
agent = self._load(texts, questions)
|
|
86
|
+
laya_questions = {q.id: q.to_laya() for q in questions}
|
|
87
|
+
results: list[dict[str, Answer]] = []
|
|
88
|
+
for start in range(0, len(texts), self.batch_size):
|
|
89
|
+
chunk = texts[start:start + self.batch_size]
|
|
90
|
+
raws = agent.predict_batch(chunk, laya_questions, batch_size=self.batch_size)
|
|
91
|
+
for raw in raws:
|
|
92
|
+
answers = raw.get("answers", raw) if isinstance(raw, dict) else {}
|
|
93
|
+
results.append({q.id: extract_answer(answers.get(q.id), q.type) for q in questions})
|
|
94
|
+
if progress:
|
|
95
|
+
progress(len(results), len(texts))
|
|
96
|
+
return results
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""LM Studio backend: a local LLM behind LM Studio's OpenAI-compatible server.
|
|
2
|
+
|
|
3
|
+
Each option gets a letter (A, B, C…) and the model answers with one token. The
|
|
4
|
+
probability the model puts on each letter (logprobs) is the confidence, so it is a
|
|
5
|
+
real probability, not a number the model writes about itself."""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
import os
|
|
11
|
+
import string
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.request
|
|
14
|
+
|
|
15
|
+
from ..questions import Question
|
|
16
|
+
from .base import Answer, Progress
|
|
17
|
+
|
|
18
|
+
DEFAULT_URL = "http://localhost:1234/v1"
|
|
19
|
+
URL_ENV = "LUAR_LMSTUDIO_URL"
|
|
20
|
+
KEY_ENV = "LMSTUDIO_API_KEY"
|
|
21
|
+
LETTERS = string.ascii_uppercase # questions are limited to 20 options
|
|
22
|
+
TOP_LOGPROBS = 20
|
|
23
|
+
|
|
24
|
+
SYSTEM_PROMPT = (
|
|
25
|
+
"You label text. Read the text, then answer the question by choosing exactly one option. "
|
|
26
|
+
"Reply with the letter of that option only."
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LMStudioError(RuntimeError):
|
|
31
|
+
"""The LM Studio server could not be used (not running, no model, auth…)."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def option_labels(q: Question) -> list[tuple[str, str]]:
|
|
35
|
+
"""[(label, description)] in the order shown to the model."""
|
|
36
|
+
if q.type == "noul":
|
|
37
|
+
return [("yes", ""), ("no", "")]
|
|
38
|
+
return list(q.options.items())
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_prompt(text: str, q: Question) -> str:
|
|
42
|
+
# The row text comes first so LM Studio can reuse its prompt cache across the
|
|
43
|
+
# questions of the same row.
|
|
44
|
+
lines = [f"Text:\n{text}", "", f"Question: {q.question}"]
|
|
45
|
+
for letter, (label, desc) in zip(LETTERS, option_labels(q)):
|
|
46
|
+
lines.append(f"{letter}) {label}: {desc}" if desc else f"{letter}) {label}")
|
|
47
|
+
lines.append("Answer:")
|
|
48
|
+
return "\n".join(lines)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def letter_probabilities(top_logprobs: list[dict], n_options: int) -> dict[str, float]:
|
|
52
|
+
"""Probability per option letter, renormalized over the valid letters only.
|
|
53
|
+
Tokens like " B" or "b" count for B."""
|
|
54
|
+
valid = LETTERS[:n_options]
|
|
55
|
+
probs = dict.fromkeys(valid, 0.0)
|
|
56
|
+
for item in top_logprobs:
|
|
57
|
+
tok = item["token"].strip().upper()
|
|
58
|
+
if tok in probs:
|
|
59
|
+
probs[tok] += math.exp(item["logprob"])
|
|
60
|
+
total = sum(probs.values())
|
|
61
|
+
return {k: v / total for k, v in probs.items()} if total else {}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def answer_from_probs(probs: dict[str, float], q: Question) -> Answer:
|
|
65
|
+
if not probs:
|
|
66
|
+
return Answer(None, None)
|
|
67
|
+
best = max(probs, key=probs.get)
|
|
68
|
+
label = option_labels(q)[LETTERS.index(best)][0]
|
|
69
|
+
return Answer(label, probs[best])
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class LMStudioBackend:
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
model: str | None = None,
|
|
76
|
+
base_url: str | None = None,
|
|
77
|
+
api_key: str | None = None,
|
|
78
|
+
timeout: float = 300,
|
|
79
|
+
):
|
|
80
|
+
self.base_url = (base_url or os.environ.get(URL_ENV) or DEFAULT_URL).rstrip("/")
|
|
81
|
+
self.api_key = api_key if api_key is not None else os.environ.get(KEY_ENV)
|
|
82
|
+
self.model = model
|
|
83
|
+
self.timeout = timeout
|
|
84
|
+
self.name = f"lmstudio ({model or 'loaded model'})"
|
|
85
|
+
|
|
86
|
+
def _request(self, path: str, body: dict | None = None, root: str | None = None) -> dict:
|
|
87
|
+
headers = {"Content-Type": "application/json"}
|
|
88
|
+
if self.api_key:
|
|
89
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
90
|
+
data = None if body is None else json.dumps(body).encode()
|
|
91
|
+
req = urllib.request.Request((root or self.base_url) + path, data=data, headers=headers)
|
|
92
|
+
try:
|
|
93
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
94
|
+
return json.load(resp)
|
|
95
|
+
except urllib.error.HTTPError as e:
|
|
96
|
+
if e.code in (401, 403):
|
|
97
|
+
raise LMStudioError(
|
|
98
|
+
f"LM Studio refused the request ({e.code}). If the server requires an API key, "
|
|
99
|
+
f"set the {KEY_ENV} environment variable."
|
|
100
|
+
) from e
|
|
101
|
+
detail = e.read().decode(errors="replace")[:300]
|
|
102
|
+
raise LMStudioError(f"LM Studio returned {e.code}: {detail}") from e
|
|
103
|
+
except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
|
|
104
|
+
raise LMStudioError(
|
|
105
|
+
f"Could not reach LM Studio at {self.base_url}. Start the server "
|
|
106
|
+
"(`lms server start`, or Developer > Start Server) and load a model."
|
|
107
|
+
) from e
|
|
108
|
+
|
|
109
|
+
def list_models(self) -> list[str]:
|
|
110
|
+
"""Models the server can use, loaded ones first; embedding models left out.
|
|
111
|
+
Uses LM Studio's own /api/v0/models (which says what is loaded) when available."""
|
|
112
|
+
root = self.base_url[:-3] if self.base_url.endswith("/v1") else self.base_url
|
|
113
|
+
try:
|
|
114
|
+
data = self._request("/api/v0/models", root=root).get("data", [])
|
|
115
|
+
except LMStudioError:
|
|
116
|
+
data = self._request("/models").get("data", [])
|
|
117
|
+
usable = [m for m in data if m.get("type") != "embeddings" and "embed" not in m["id"].lower()]
|
|
118
|
+
usable.sort(key=lambda m: m.get("state") != "loaded") # stable: keeps server order otherwise
|
|
119
|
+
return [m["id"] for m in usable]
|
|
120
|
+
|
|
121
|
+
def resolve_model(self) -> str:
|
|
122
|
+
if not self.model:
|
|
123
|
+
models = self.list_models()
|
|
124
|
+
if not models:
|
|
125
|
+
raise LMStudioError("No model is available in LM Studio; load one (e.g. `lms load qwen3.5-4b`).")
|
|
126
|
+
self.model = models[0]
|
|
127
|
+
self.name = f"lmstudio ({self.model})"
|
|
128
|
+
return self.model
|
|
129
|
+
|
|
130
|
+
def ask(self, text: str, q: Question) -> Answer:
|
|
131
|
+
body = {
|
|
132
|
+
"model": self.resolve_model(),
|
|
133
|
+
"messages": [
|
|
134
|
+
{"role": "system", "content": SYSTEM_PROMPT},
|
|
135
|
+
{"role": "user", "content": build_prompt(text, q)},
|
|
136
|
+
],
|
|
137
|
+
"max_tokens": 1,
|
|
138
|
+
"temperature": 0,
|
|
139
|
+
"logprobs": True,
|
|
140
|
+
"top_logprobs": TOP_LOGPROBS,
|
|
141
|
+
"reasoning_effort": "none", # answer directly, no "thinking" tokens
|
|
142
|
+
}
|
|
143
|
+
resp = self._request("/chat/completions", body)
|
|
144
|
+
try:
|
|
145
|
+
top = resp["choices"][0]["logprobs"]["content"][0]["top_logprobs"]
|
|
146
|
+
except (KeyError, IndexError, TypeError) as e:
|
|
147
|
+
raise LMStudioError(
|
|
148
|
+
"LM Studio did not return token probabilities (logprobs); update LM Studio."
|
|
149
|
+
) from e
|
|
150
|
+
return answer_from_probs(letter_probabilities(top, len(option_labels(q))), q)
|
|
151
|
+
|
|
152
|
+
def decide(
|
|
153
|
+
self,
|
|
154
|
+
texts: list[str],
|
|
155
|
+
questions: list[Question],
|
|
156
|
+
progress: Progress | None = None,
|
|
157
|
+
) -> list[dict[str, Answer]]:
|
|
158
|
+
results = []
|
|
159
|
+
for i, text in enumerate(texts):
|
|
160
|
+
results.append({q.id: self.ask(text, q) for q in questions})
|
|
161
|
+
if progress:
|
|
162
|
+
progress(i + 1, len(texts))
|
|
163
|
+
return results
|