File size: 12,497 Bytes
ea1a330
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
# -*- coding: utf-8 -*-
"""KnowledgeGraphEval 2026 · Subtask 1 (AdaptNER) — generative NER via a HOSTED QWEN endpoint.

Standalone, local, resume-friendly. Mirrors the AlexandriaX `alexandriax_task1_qwen_hosted.py`
pattern: instead of training, it drives an existing **hosted Qwen inference endpoint**
(OpenAI-compatible, e.g. the vLLM server on the femad/data-intelligence stack) to extract
named entities from the Konooz dev/eval sentences, then writes the official CodaBench
submission (token + 21 B/I/O columns, one concatenated file).

Design
------
* No training / no GPU. Pure inference against an existing hosted model.
* Runs on your PC. Outputs saved next to this file under ``qwen_runs/``.
* Direct OpenAI-compatible ``/v1/chat/completions`` call — no orchestrator, no tracing.
* Resume-safe. Each sentence's raw model output is cached to a local JSONL as it lands;
  re-run and it only queries the sentences still missing.

Endpoint config (env vars — sane defaults)
------------------------------------------
    QWEN_BASE_URL     OpenAI-compatible base URL. Default: http://localhost:8005/v1
                      (reach a remote server via: ssh -N -L 8005:localhost:8005 <host>)
    QWEN_MODEL        Model name served by the endpoint. Default: qwen3-3b
                      (SET THIS to whatever your Qwen 3B is registered as; check
                      with:  curl http://localhost:8005/v1/models )
    QWEN_API_KEY      vLLM ignores it; any non-empty string. Default: "EMPTY".
    QWEN_HOST_HEADER  Proxy vhost when tunnelling (e.g. "100.64.0.2:8005"). Default: none.
    QWEN_NO_THINK     "1" (default) disables qwen3 thinking mode (harmless on Instruct).
    QWEN_CONCURRENCY  Parallel in-flight requests. Default: 8.
    QWEN_TEMPERATURE  Sampling temperature. Default: 0.0 (deterministic).
    QWEN_MAX_TOKENS   Max generated tokens per sentence. Default: 512.
    QWEN_MAX_RETRIES  Transient-error retries. Default: 4.

Data config
-----------
    KONOOZ_SRC        Path to a .zip OR a folder of <Domain>.txt files.
                      Default: auto-find under ../../data/raw/konooz_dev then dev-konooz.zip.

Run
---
    pip install openai tqdm
    QWEN_MODEL=qwen2-2b python kgeval_task1_qwen_hosted.py
"""
from __future__ import annotations

import glob
import io
import json
import os
import re
import zipfile
from concurrent.futures import ThreadPoolExecutor, as_completed

from tqdm.auto import tqdm

# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
ROOT = os.path.dirname(os.path.abspath(__file__))
WORK_DIR = os.path.join(ROOT, "qwen_runs")
SUBMIT_DIR = os.path.join(WORK_DIR, "submissions_qwen")
os.makedirs(SUBMIT_DIR, exist_ok=True)

NAME = os.environ.get("RUN_NAME", "qwen3b_hosted")
CACHE_FILE = os.path.join(SUBMIT_DIR, f"cache_{NAME}.jsonl")

# Official 21 entity types, in submission column order.
TYPE_ORDER = [
    "CARDINAL", "CURR", "DATE", "EVENT", "FAC", "GPE", "LANGUAGE", "LAW", "LOC", "MONEY",
    "NORP", "OCC", "ORDINAL", "ORG", "PERCENT", "PERS", "PRODUCT", "QUANTITY", "TIME", "UNIT", "WEBSITE",
]

# --- hosted qwen endpoint (direct, untraced) ---
QWEN_BASE_URL = os.environ.get("QWEN_BASE_URL") or os.environ.get("LLM_API_BASE_URL") or "http://localhost:8005/v1"
QWEN_MODEL = os.environ.get("QWEN_MODEL") or os.environ.get("LLM_MODEL_NAME") or "qwen3-3b"
QWEN_API_KEY = os.environ.get("QWEN_API_KEY", "EMPTY")
QWEN_HOST_HEADER = os.environ.get("QWEN_HOST_HEADER", "")
QWEN_NO_THINK = os.environ.get("QWEN_NO_THINK", "1") == "1"
CONCURRENCY = int(os.environ.get("QWEN_CONCURRENCY", "8"))
TEMPERATURE = float(os.environ.get("QWEN_TEMPERATURE", "0.0"))
MAX_TOKENS = int(os.environ.get("QWEN_MAX_TOKENS", "512"))
MAX_RETRIES = int(os.environ.get("QWEN_MAX_RETRIES", "4"))

KONOOZ_SRC = os.environ.get("KONOOZ_SRC", "")

# ---------------------------------------------------------------------------
# Data loading (CoNLL: token + N columns; blank line = sentence boundary)
# ---------------------------------------------------------------------------
def resolve_konooz_src() -> str:
    if KONOOZ_SRC:
        return KONOOZ_SRC
    guesses = [
        os.path.join(ROOT, "..", "..", "data", "raw", "konooz_dev"),
        os.path.join(ROOT, "..", "..", "data", "raw", "dev-konooz.zip"),
        os.path.join(ROOT, "..", "..", "data", "raw", "devkonooz.zip"),
    ]
    for g in guesses:
        if os.path.exists(g):
            return g
    raise FileNotFoundError("Set KONOOZ_SRC to the Konooz dev .zip or folder.")


def load_domain_files(src: str):
    if str(src).endswith(".zip"):
        work = os.path.join(WORK_DIR, "konooz_dev")
        os.makedirs(work, exist_ok=True)
        with zipfile.ZipFile(src) as z:
            z.extractall(work)
        src = work
    files = sorted(glob.glob(os.path.join(src, "**", "*.txt"), recursive=True))
    if not files:
        raise FileNotFoundError(f"no .txt files under {src}")
    return files


def read_conll(path: str):
    sents, cur = [], []
    for line in io.open(path, encoding="utf-8"):
        s = line.rstrip("\n")
        if not s.strip():
            if cur:
                sents.append(cur)
                cur = []
            continue
        cur.append(s.split())  # parts[0] = token, parts[1:] = placeholder tags (ignored)
    if cur:
        sents.append(cur)
    return sents


# ---------------------------------------------------------------------------
# Qwen client (direct OpenAI-compatible call)
# ---------------------------------------------------------------------------
from openai import OpenAI

_client = OpenAI(
    base_url=QWEN_BASE_URL,
    api_key=QWEN_API_KEY,
    max_retries=0,
    default_headers={"Host": QWEN_HOST_HEADER} if QWEN_HOST_HEADER else None,
)

_TYPE_LIST_STR = ", ".join(TYPE_ORDER)
_SYSTEM = (
    "You are an expert Arabic Named Entity Recognition system. "
    "Given an Arabic sentence, extract every named entity. "
    "Allowed entity types (use these labels exactly): " + _TYPE_LIST_STR + ". "
    "Entities may be nested (a span can belong to more than one type). "
    "Output ONLY the entities, one per line, as:  <surface text><TAB><TYPE>. "
    "Copy the surface text exactly as it appears in the sentence. "
    "If there are no entities, output nothing."
)

_THINK_RE = re.compile(r"<think>.*?</think>", re.DOTALL | re.IGNORECASE)
_SPECIAL_TOK_RE = re.compile(r"<\|[^|>]*\|>")


def clean_output(text: str) -> str:
    if not text:
        return ""
    text = _THINK_RE.sub("", text)
    if "</think>" in text:
        text = text.split("</think>")[-1]
    return _SPECIAL_TOK_RE.sub("", text).strip()


def extract_one(sentence: str) -> str:
    """Return the raw model text (entity lines) for one sentence."""
    if not sentence.strip():
        return ""
    kwargs = dict(
        model=QWEN_MODEL,
        messages=[{"role": "system", "content": _SYSTEM},
                  {"role": "user", "content": sentence}],
        temperature=TEMPERATURE,
        max_tokens=MAX_TOKENS,
    )
    if QWEN_NO_THINK:
        kwargs["extra_body"] = {"chat_template_kwargs": {"enable_thinking": False}}
    last_err = None
    for _ in range(MAX_RETRIES):
        try:
            resp = _client.chat.completions.create(**kwargs)
            return clean_output(resp.choices[0].message.content or "")
        except Exception as e:  # transient network / 5xx / rate-limit
            last_err = e
    raise RuntimeError(f"qwen call failed after {MAX_RETRIES} retries: {last_err}")


# ---------------------------------------------------------------------------
# Parse model text -> per-token 21-column tags
# ---------------------------------------------------------------------------
_TYPESET = set(TYPE_ORDER)


def parse_entities(text: str):
    ents = []
    for ln in text.splitlines():
        ln = ln.strip()
        if not ln:
            continue
        parts = re.split(r"\t|\s+-\s+|\s*:\s*|\s*\|\s*", ln)
        if len(parts) >= 2:
            typ = parts[-1].strip().upper()
            surf = " ".join(parts[:-1]).strip().strip("\"'“”«»")
            if typ in _TYPESET and surf:
                ents.append((surf, typ))
    return ents


def entity_tagsets(tokens, ents):
    """Align each entity surface (whitespace tokens) onto `tokens`; per-token set of B-/I- tags."""
    tagsets = [set() for _ in tokens]
    for surf, typ in ents:
        span = surf.split()
        if not span:
            continue
        for i in range(len(tokens) - len(span) + 1):
            if tokens[i:i + len(span)] == span:
                tagsets[i].add(f"B-{typ}")
                for j in range(i + 1, i + len(span)):
                    tagsets[j].add(f"I-{typ}")
    return tagsets


def tags_to_columns(tagset):
    cols = []
    for typ in TYPE_ORDER:
        if f"B-{typ}" in tagset:
            cols.append(f"B-{typ}")
        elif f"I-{typ}" in tagset:
            cols.append(f"I-{typ}")
        else:
            cols.append("O")
    return cols


def read_jsonl(p):
    rows = []
    with io.open(p, encoding="utf-8") as h:
        for line in h:
            if line.strip():
                rows.append(json.loads(line))
    return rows


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
    print(f"endpoint : {QWEN_BASE_URL}")
    print(f"model    : {QWEN_MODEL}  (no_think={QWEN_NO_THINK}, temp={TEMPERATURE}, conc={CONCURRENCY})")
    print(f"work dir : {WORK_DIR}")

    src = resolve_konooz_src()
    files = load_domain_files(src)
    data = {os.path.splitext(os.path.basename(f))[0]: read_conll(f) for f in files}
    total = sum(len(v) for v in data.values())
    print(f"data     : {len(files)} domains, {total} sentences ({src})")

    # --- sanity: first 2 sentences ---
    print("\n--- sanity (first 2 sentences) ---")
    dom0 = sorted(data)[0]
    for sent in data[dom0][:2]:
        toks = [r[0] for r in sent]
        raw = extract_one(" ".join(toks))
        print(f"[{dom0}] {' '.join(toks)[:50]!r} -> {parse_entities(raw)[:4]}")
    print()

    # --- resume from cache (keyed by domain, sentence index) ---
    cache = {}
    if os.path.exists(CACHE_FILE):
        for r in read_jsonl(CACHE_FILE):
            cache[(r["domain"], int(r["sidx"]))] = r["text"]
        print(f"[resume] {len(cache)} sentences already cached")

    todo = []
    for dom in sorted(data):
        for sidx, sent in enumerate(data[dom]):
            if (dom, sidx) not in cache:
                todo.append((dom, sidx, " ".join(r[0] for r in sent)))
    print("remaining sentences:", len(todo))

    if todo:
        fh = io.open(CACHE_FILE, "a", encoding="utf-8")
        try:
            with ThreadPoolExecutor(max_workers=CONCURRENCY) as pool:
                futs = {pool.submit(extract_one, s): (d, i) for (d, i, s) in todo}
                for fut in tqdm(as_completed(futs), total=len(futs), desc="extract", unit="sent"):
                    d, i = futs[fut]
                    text = fut.result()
                    cache[(d, i)] = text
                    fh.write(json.dumps({"domain": d, "sidx": i, "text": text}, ensure_ascii=False) + "\n")
                    fh.flush()
        finally:
            fh.close()
    print("total cached:", len(cache))

    # --- build official submission: token + 21 columns, domains concatenated alphabetically ---
    pred_name = f"namaa_pred_{NAME}.txt"
    pred_path = os.path.join(SUBMIT_DIR, pred_name)
    n_ent = 0
    with io.open(pred_path, "w", encoding="utf-8") as f:
        for dom in sorted(data):
            for sidx, sent in enumerate(data[dom]):
                toks = [r[0] for r in sent]
                ents = parse_entities(cache.get((dom, sidx), ""))
                n_ent += len(ents)
                tagsets = entity_tagsets(toks, ents)
                for w, tok in enumerate(toks):
                    f.write(tok + " " + " ".join(tags_to_columns(tagsets[w])) + "\n")
                f.write("\n")
    print(f"predictions -> {pred_path}  (entities aligned: {n_ent})")

    zip_path = os.path.join(SUBMIT_DIR, f"submission_{NAME}.zip")
    with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as z:
        z.write(pred_path, arcname=pred_name)
    print("submission ->", zip_path)
    print("[done] upload submission zip to CodaBench.")


if __name__ == "__main__":
    main()