初始化项目,由ModelHub XC社区提供模型
Model: eivintobias/heartly-qwen-code Source: Original Platform
This commit is contained in:
36
.gitattributes
vendored
Normal file
36
.gitattributes
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
*.7z filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.arrow filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.bin filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.ftz filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.gz filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.h5 filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.joblib filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.model filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.npy filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.npz filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.onnx filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.ot filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.parquet filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.pb filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.pickle filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.pkl filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.pt filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.pth filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.rar filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
||||||
|
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.tar filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.tflite filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.tgz filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.wasm filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.xz filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.zip filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.zst filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
||||||
|
tokenizer.json filter=lfs diff=lfs merge=lfs -text
|
||||||
116
README.md
Normal file
116
README.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
---
|
||||||
|
language:
|
||||||
|
- en
|
||||||
|
license: mit
|
||||||
|
base_model: Qwen/Qwen2.5-Coder-1.5B
|
||||||
|
library_name: transformers
|
||||||
|
model_type: qwen2
|
||||||
|
tags:
|
||||||
|
- code
|
||||||
|
- hallucination-reduction
|
||||||
|
- heartly
|
||||||
|
- decide-verify-stop
|
||||||
|
- boundary-head
|
||||||
|
- pytorch
|
||||||
|
- text-generation
|
||||||
|
---
|
||||||
|
|
||||||
|
# Heartly Qwen-Code v3
|
||||||
|
|
||||||
|
A 1.5B coding LLM with the **Heartly** hallucination-reduction architecture,
|
||||||
|
fine-tuned from **Qwen2.5-Coder-1.5B** with the conversational Stage-5 SFT recipe
|
||||||
|
(Fix1–4: natural phrasing, single refusal, persona — 5,200 samples in
|
||||||
|
`heartly-qwen-code/sft_dataset_code_v3.jsonl`).
|
||||||
|
|
||||||
|
v3 builds on the same v1/v2 Stage 1–4 numbers (grammar adoption 100%, boundary-head
|
||||||
|
AUROC 1.000, critic AUROC 1.000) — same Qwen2.5-Coder-1.5B base, now trained for
|
||||||
|
multi-turn conversational code chat. See [`HF_MODEL_CARD.md`](HF_MODEL_CARD.md) for
|
||||||
|
the Stage 1–2 probe/critic results carried over from the identical architecture.
|
||||||
|
|
||||||
|
## Output grammar
|
||||||
|
|
||||||
|
```
|
||||||
|
thinking [reasoning] response<decide>speak|stop</decide><verify>known|unknown</verify> [answer] <stop>
|
||||||
|
```
|
||||||
|
|
||||||
|
Only `[answer]` should reach the user.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### 0. Recommended — chat via the GitHub server (strips the grammar for you)
|
||||||
|
|
||||||
|
This model emits the Heartly grammar as ordinary multi-token text (the tags are
|
||||||
|
**not** tokenizer special tokens), so some front-ends (e.g. LM Studio) may decode
|
||||||
|
them mangled. The **`server.py`** FastAPI loader on GitHub loads this model and
|
||||||
|
runs every reply through **`reply_formatter.py`**, which canonicalises the tags
|
||||||
|
and returns only the clean answer.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt # fastapi + uvicorn + transformers + torch
|
||||||
|
python server.py --model eivintobias/heartly-qwen-code --port 8000
|
||||||
|
|
||||||
|
curl -X POST http://127.0.0.1:8000/chat \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"prompt":"Write a function that reverses a string"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Response: `{"model":"eivintobias/heartly-qwen-code","raw":"...<decide>...","reply":"<clean answer>"}`.
|
||||||
|
|
||||||
|
Quick browser test (no curl): open `http://127.0.0.1:8000/` — `server.py` serves an
|
||||||
|
HTML chat UI at `GET /`. The first message lazy-loads the model; code answers render
|
||||||
|
with real line breaks, and the Heartly grammar is stripped by the reply formatter.
|
||||||
|
|
||||||
|
Quick offline test (no server): `python chat_smoke.py "Write a function that sorts a list"`.
|
||||||
|
|
||||||
|
📦 **Model card source:** this file (`HF_MODEL_CARD_v3.md`). When uploaded to
|
||||||
|
HuggingFace, copy it to `README.md` on the hub repo.
|
||||||
|
|
||||||
|
### 1. Transformers
|
||||||
|
```python
|
||||||
|
from transformers import AutoModelForCausalLM, AutoTokenizer
|
||||||
|
import torch
|
||||||
|
tok = AutoTokenizer.from_pretrained("eivintobias/heartly-qwen-code")
|
||||||
|
model = AutoModelForCausalLM.from_pretrained(
|
||||||
|
"eivintobias/heartly-qwen-code", torch_dtype=torch.float32, device_map="cpu"
|
||||||
|
)
|
||||||
|
model.eval()
|
||||||
|
ids = tok.encode("User: Write a function that reverses a string\nAssistant: ", return_tensors="pt")
|
||||||
|
out = model.generate(**ids, max_new_tokens=256, pad_token_id=tok.eos_token_id, do_sample=False)
|
||||||
|
raw = tok.decode(out[0][ids.shape[1]:], skip_special_tokens=False)
|
||||||
|
# Strip the grammar -> clean answer:
|
||||||
|
from reply_formatter import format_reply
|
||||||
|
print(format_reply(raw))
|
||||||
|
```
|
||||||
|
|
||||||
|
> `reply_formatter.py` (grammar strip) and `server.py` are bundled in this repo (HF clone = flat layout; GitHub = `heartly-qwen-code/`). Clone it so `from reply_formatter import format_reply` resolves before the offline example.
|
||||||
|
|
||||||
|
## Files in this repo
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `config.json` | Qwen2ForCausalLM (28 layers, d=1536) + `heartly_stop_token_id=9495` |
|
||||||
|
| `generation_config.json` | default generate params |
|
||||||
|
| `chat_template.jinja` | standard Qwen chat template |
|
||||||
|
| `tokenizer.json` / `tokenizer_config.json` | Qwen BPE tokenizer |
|
||||||
|
| `model.safetensors` | v3 fine-tuned weights (**full fine-tune**, not a LoRA adapter) |
|
||||||
|
| `server.py` | FastAPI server: lazy-loads the model; serves `/`, `/health`, `/chat`; strips Heartly grammar via reply_formatter |
|
||||||
|
| `reply_formatter.py` | strips `thinking` / `<decide>` / `<verify>` / `<stop>` -> clean answer; unescapes code newlines |
|
||||||
|
| `chat_smoke.py` | offline load + chat smoke test (no server) |
|
||||||
|
| `requirements.txt` | torch, transformers, fastapi, uvicorn, sentencepiece, datasets, scikit-learn, numpy, accelerate, huggingface_hub |
|
||||||
|
|
||||||
|
## Training
|
||||||
|
|
||||||
|
- **Base:** Qwen/Qwen2.5-Coder-1.5B
|
||||||
|
- **Method:** full fine-tune (fp16), max-length 512, 2 epochs, freeze bottom 12 layers
|
||||||
|
- **Dataset:** `sft_dataset_code_v3.jsonl` (5,200 conversational Heartly samples)
|
||||||
|
- **GPU:** 1× RTX 3090 (24GB)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT — built on Qwen2.5-Coder (Apache 2.0).
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- [GitHub (server + tools)](https://github.com/eivintobias/heartly/tree/master/heartly-qwen-code)
|
||||||
|
- [v1/v2 model card (LoRA)](HF_MODEL_CARD.md)
|
||||||
|
- [Heartly RWKV7 model](https://huggingface.co/eivintobias/heartly-rwkv7-1.5b)
|
||||||
68
chat_smoke.py
Normal file
68
chat_smoke.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""chat_smoke.py - quick offline test of heartly-qwen-code-v3.
|
||||||
|
|
||||||
|
Loads the model once and runs a prompt through the SAME loader + reply_formatter
|
||||||
|
that server.py uses, printing RAW (grammar) and REPLY (clean). No HTTP server.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python chat_smoke.py "Write a function that reverses a string"
|
||||||
|
python chat_smoke.py --mode debug --prompt "Explain a closure"
|
||||||
|
echo "What is a closure?" | python chat_smoke.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import torch
|
||||||
|
from transformers import AutoModelForCausalLM, AutoTokenizer
|
||||||
|
|
||||||
|
from reply_formatter import format_reply
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p = argparse.ArgumentParser(description="Smoke-test heartly-qwen-code-v3")
|
||||||
|
p.add_argument("prompt_pos", nargs="?", default=None,
|
||||||
|
help="question to ask (positional, e.g. chat_smoke.py 'reverse a list')")
|
||||||
|
p.add_argument("--prompt", "-p", dest="prompt_opt", default=None,
|
||||||
|
help="question to ask (alternative to positional)")
|
||||||
|
p.add_argument("--model", default="heartly-qwen-code-v3")
|
||||||
|
p.add_argument("--mode", default="chat", choices=["chat", "debug", "raw"])
|
||||||
|
p.add_argument("--max-new-tokens", type=int, default=200)
|
||||||
|
a = p.parse_args()
|
||||||
|
|
||||||
|
# Precedence: --prompt > positional > stdin > hard-coded default.
|
||||||
|
if a.prompt_opt is not None:
|
||||||
|
prompt = a.prompt_opt
|
||||||
|
elif a.prompt_pos is not None:
|
||||||
|
prompt = a.prompt_pos
|
||||||
|
else:
|
||||||
|
prompt = sys.stdin.read().strip()
|
||||||
|
if not prompt:
|
||||||
|
prompt = "Write a function that reverses a string" # safety default
|
||||||
|
|
||||||
|
print(f"Loading {a.model} ...", file=sys.stderr)
|
||||||
|
tok = AutoTokenizer.from_pretrained(a.model)
|
||||||
|
model = AutoModelForCausalLM.from_pretrained(
|
||||||
|
a.model, torch_dtype=torch.float32, device_map="cpu"
|
||||||
|
).eval()
|
||||||
|
|
||||||
|
ids = tok.encode(f"User: {prompt}\nAssistant: ", return_tensors="pt")
|
||||||
|
with torch.no_grad():
|
||||||
|
out = model.generate(
|
||||||
|
ids,
|
||||||
|
max_new_tokens=a.max_new_tokens,
|
||||||
|
pad_token_id=tok.eos_token_id,
|
||||||
|
do_sample=False,
|
||||||
|
)
|
||||||
|
raw = tok.decode(out[0][ids.shape[1]:], skip_special_tokens=False)
|
||||||
|
reply = format_reply(raw, mode=a.mode)
|
||||||
|
|
||||||
|
print("\n=== RAW ===")
|
||||||
|
print(raw)
|
||||||
|
print("\n=== REPLY (" + a.mode + ") ===")
|
||||||
|
print(reply)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
54
chat_template.jinja
Normal file
54
chat_template.jinja
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
{%- if tools %}
|
||||||
|
{{- '<|im_start|>system\n' }}
|
||||||
|
{%- if messages[0]['role'] == 'system' %}
|
||||||
|
{{- messages[0]['content'] }}
|
||||||
|
{%- else %}
|
||||||
|
{{- 'You are Qwen, created by Alibaba Cloud. You are a helpful assistant.' }}
|
||||||
|
{%- endif %}
|
||||||
|
{{- "\n\n# Tools\n\nYou may call one or more functions to assist with the user query.\n\nYou are provided with function signatures within <tools></tools> XML tags:\n<tools>" }}
|
||||||
|
{%- for tool in tools %}
|
||||||
|
{{- "\n" }}
|
||||||
|
{{- tool | tojson }}
|
||||||
|
{%- endfor %}
|
||||||
|
{{- "\n</tools>\n\nFor each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:\n<tool_call>\n{\"name\": <function-name>, \"arguments\": <args-json-object>}\n</tool_call><|im_end|>\n" }}
|
||||||
|
{%- else %}
|
||||||
|
{%- if messages[0]['role'] == 'system' %}
|
||||||
|
{{- '<|im_start|>system\n' + messages[0]['content'] + '<|im_end|>\n' }}
|
||||||
|
{%- else %}
|
||||||
|
{{- '<|im_start|>system\nYou are Qwen, created by Alibaba Cloud. You are a helpful assistant.<|im_end|>\n' }}
|
||||||
|
{%- endif %}
|
||||||
|
{%- endif %}
|
||||||
|
{%- for message in messages %}
|
||||||
|
{%- if (message.role == "user") or (message.role == "system" and not loop.first) or (message.role == "assistant" and not message.tool_calls) %}
|
||||||
|
{{- '<|im_start|>' + message.role + '\n' + message.content + '<|im_end|>' + '\n' }}
|
||||||
|
{%- elif message.role == "assistant" %}
|
||||||
|
{{- '<|im_start|>' + message.role }}
|
||||||
|
{%- if message.content %}
|
||||||
|
{{- '\n' + message.content }}
|
||||||
|
{%- endif %}
|
||||||
|
{%- for tool_call in message.tool_calls %}
|
||||||
|
{%- if tool_call.function is defined %}
|
||||||
|
{%- set tool_call = tool_call.function %}
|
||||||
|
{%- endif %}
|
||||||
|
{{- '\n<tool_call>\n{"name": "' }}
|
||||||
|
{{- tool_call.name }}
|
||||||
|
{{- '", "arguments": ' }}
|
||||||
|
{{- tool_call.arguments | tojson }}
|
||||||
|
{{- '}\n</tool_call>' }}
|
||||||
|
{%- endfor %}
|
||||||
|
{{- '<|im_end|>\n' }}
|
||||||
|
{%- elif message.role == "tool" %}
|
||||||
|
{%- if (loop.index0 == 0) or (messages[loop.index0 - 1].role != "tool") %}
|
||||||
|
{{- '<|im_start|>user' }}
|
||||||
|
{%- endif %}
|
||||||
|
{{- '\n<tool_response>\n' }}
|
||||||
|
{{- message.content }}
|
||||||
|
{{- '\n</tool_response>' }}
|
||||||
|
{%- if loop.last or (messages[loop.index0 + 1].role != "tool") %}
|
||||||
|
{{- '<|im_end|>\n' }}
|
||||||
|
{%- endif %}
|
||||||
|
{%- endif %}
|
||||||
|
{%- endfor %}
|
||||||
|
{%- if add_generation_prompt %}
|
||||||
|
{{- '<|im_start|>assistant\n' }}
|
||||||
|
{%- endif %}
|
||||||
62
config.json
Normal file
62
config.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"architectures": [
|
||||||
|
"Qwen2ForCausalLM"
|
||||||
|
],
|
||||||
|
"attention_dropout": 0.0,
|
||||||
|
"bos_token_id": 151643,
|
||||||
|
"dtype": "bfloat16",
|
||||||
|
"eos_token_id": 151643,
|
||||||
|
"hidden_act": "silu",
|
||||||
|
"hidden_size": 1536,
|
||||||
|
"initializer_range": 0.02,
|
||||||
|
"intermediate_size": 8960,
|
||||||
|
"layer_types": [
|
||||||
|
"full_attention",
|
||||||
|
"full_attention",
|
||||||
|
"full_attention",
|
||||||
|
"full_attention",
|
||||||
|
"full_attention",
|
||||||
|
"full_attention",
|
||||||
|
"full_attention",
|
||||||
|
"full_attention",
|
||||||
|
"full_attention",
|
||||||
|
"full_attention",
|
||||||
|
"full_attention",
|
||||||
|
"full_attention",
|
||||||
|
"full_attention",
|
||||||
|
"full_attention",
|
||||||
|
"full_attention",
|
||||||
|
"full_attention",
|
||||||
|
"full_attention",
|
||||||
|
"full_attention",
|
||||||
|
"full_attention",
|
||||||
|
"full_attention",
|
||||||
|
"full_attention",
|
||||||
|
"full_attention",
|
||||||
|
"full_attention",
|
||||||
|
"full_attention",
|
||||||
|
"full_attention",
|
||||||
|
"full_attention",
|
||||||
|
"full_attention",
|
||||||
|
"full_attention"
|
||||||
|
],
|
||||||
|
"max_position_embeddings": 32768,
|
||||||
|
"max_window_layers": 28,
|
||||||
|
"model_type": "qwen2",
|
||||||
|
"num_attention_heads": 12,
|
||||||
|
"num_hidden_layers": 28,
|
||||||
|
"num_key_value_heads": 2,
|
||||||
|
"pad_token_id": null,
|
||||||
|
"rms_norm_eps": 1e-06,
|
||||||
|
"rope_parameters": {
|
||||||
|
"rope_theta": 1000000.0,
|
||||||
|
"rope_type": "default"
|
||||||
|
},
|
||||||
|
"sliding_window": null,
|
||||||
|
"tie_word_embeddings": true,
|
||||||
|
"transformers_version": "5.14.1",
|
||||||
|
"use_cache": false,
|
||||||
|
"use_sliding_window": false,
|
||||||
|
"vocab_size": 151936,
|
||||||
|
"heartly_stop_token_id": 9495
|
||||||
|
}
|
||||||
6
generation_config.json
Normal file
6
generation_config.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"bos_token_id": 151643,
|
||||||
|
"eos_token_id": 151643,
|
||||||
|
"max_new_tokens": 2048,
|
||||||
|
"transformers_version": "5.14.1"
|
||||||
|
}
|
||||||
3
model.safetensors
Normal file
3
model.safetensors
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:23faa74e0f892a87cfd8a8fda7f3c8c777465d50fa41c91b18d715c829de940d
|
||||||
|
size 3087467144
|
||||||
452
reply_formatter.py
Normal file
452
reply_formatter.py
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
reply_formatter.py -- extract the user-visible answer from a Heartly
|
||||||
|
Qwen-Code (v3) grammar output.
|
||||||
|
|
||||||
|
The v3 model is trained to emit:
|
||||||
|
thinking {reasoning} response<decide>speak|stop</decide><verify>known|unknown</verify> {answer} <stop>
|
||||||
|
|
||||||
|
(or `<decide>stop</decide>` for the silence case).
|
||||||
|
|
||||||
|
The user should only ever see {answer}, never the scaffolding. In practice the
|
||||||
|
model -- especially through a GUI like LM Studio -- produces *noisy* variants:
|
||||||
|
the tags are NOT registered as tokenizer special tokens, so Qwen's BPE shatters
|
||||||
|
`<decide>` into subwords that can re-decode mangled (e.g. ``<deside>``), the
|
||||||
|
`<stop>` end-marker can come back truncated (no closing `>`), and verify values
|
||||||
|
can carry junk (`<verify>known, true</verify>`). Qwen chat-template tokens also
|
||||||
|
leak as plain text (`<stop> <begin> <sep>`).
|
||||||
|
|
||||||
|
This parser is tolerant: it canonicalises the tags, extracts the structured
|
||||||
|
fields, and has an aggressive fallback so NO grammar token ever reaches the user.
|
||||||
|
|
||||||
|
Modes:
|
||||||
|
"chat" -- only the clean answer (default)
|
||||||
|
"debug" -- answer + [decide=X verify=Y] metadata per turn
|
||||||
|
"raw" -- the original raw output, untouched
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from reply_formatter import format_reply, clean_reply
|
||||||
|
shown = format_reply(raw_model_output)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Junk tokens -- decoded-to-plain-text Qwen special tokens that are never
|
||||||
|
# answer content. Stripped first so they cannot confuse grammar parsing.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_JUNK_TOKENS = (
|
||||||
|
"<|im_start|>", "<|im_end|>", "im_start", "im_end",
|
||||||
|
"<|object_ref_start|>", "<|object_ref_end|>",
|
||||||
|
"<|box_start|>", "<|box_end|>",
|
||||||
|
"<|quad_start|>", "<|quad_end|>",
|
||||||
|
"<|vision_start|>", "<|vision_end|>", "<|vision_pad|>", "<|image_pad|>",
|
||||||
|
"<|endoftext|>", "endoftext",
|
||||||
|
"<|tool_call_begin|>", "<|tool_call_end|>", "<|tool_call_argument_begin|>",
|
||||||
|
"<|tool_call_argument_end|>", "<|tool_call_argument_name|>", "<|tool_call_argument|>",
|
||||||
|
"<|tool_calls_section_begin|>", "<|tool_calls_section_end|>",
|
||||||
|
"<tool_calls>", "</tool_calls>", "tool_call", "tool_calls",
|
||||||
|
"<begin>", "<sep>",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Meta-commentary patterns -- the model's self-talk about what it knows /
|
||||||
|
# doesn't know. If any bleeds into the answer zone, strip it. Sourced from
|
||||||
|
# the reasoning / refusal / silence templates in render_code_sft_v3.py.
|
||||||
|
# (The think block is removed entirely in the common case, so this is a
|
||||||
|
# safety net, not the primary mechanism.)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_META_PATTERNS = [
|
||||||
|
r"\bI (?:do )?know (?:how )?to do this\b[^.]*\.",
|
||||||
|
r"\bI (?:do )?know how to write this\b[^.]*\.",
|
||||||
|
r"\bI (?:do )?know this function well\b[^.]*\.",
|
||||||
|
r"\bI (?:can|will) (?:write|produce|implement|respond|answer|write clean code)\b[^.]*\.",
|
||||||
|
r"\bThis is a standard programming task\b[^.]*\.",
|
||||||
|
r"\bI recognise this programming problem\b[^.]*\.",
|
||||||
|
r"\bI (?:recognise|recognize) this (?:programming|pattern|algorithm)\b[^.]*\.",
|
||||||
|
r"\bStandard problem\b[^.]*\.",
|
||||||
|
r"\bClear task\b[^.]*\.",
|
||||||
|
r"\bI've seen this pattern before\b[^.]*\.",
|
||||||
|
r"\bThis is straightforward\b[^.]*\.",
|
||||||
|
r"\bI (?:do )?not know this API or library\b[^.]*\.",
|
||||||
|
r"\bI should not invent a solution\b[^.]*\.",
|
||||||
|
r"\bI have no knowledge of this framework\b[^.]*\.",
|
||||||
|
r"\bThe honest response is\b[^.]*\.",
|
||||||
|
r"\bI cannot verify the correct implementation\b[^.]*\.",
|
||||||
|
r"\bMaking something up would be worse than admitting it\b[^.]*\.",
|
||||||
|
r"\bI'll say so rather than produce fake code\b[^.]*\.",
|
||||||
|
r"\bThe input is empty or not a real question\b[^.]*\.",
|
||||||
|
r"\bNo meaningful request was made\b[^.]*\.",
|
||||||
|
r"\bSpeaking would add nothing\b[^.]*\.",
|
||||||
|
r"\bSocial turn\b[^.]*\.",
|
||||||
|
r"\bGreeting[ —-]\s*respond\b[^.]*\.",
|
||||||
|
r"\bNot a factual question\b[^.]*\.",
|
||||||
|
r"\bCasual conversation\b[^.]*\.",
|
||||||
|
r"\bFollow-up question\b[^.]*\.",
|
||||||
|
r"\bThey need motivation\b[^.]*\.",
|
||||||
|
r"\bEmotional context\b[^.]*\.",
|
||||||
|
r"\bMeta-conversation about how we work together\b[^.]*\.",
|
||||||
|
r"\bBe warm\b[^.]*\.",
|
||||||
|
r"\bThey (?:want|are|need|'re|'ve|gave) ",
|
||||||
|
r"\bThey're (?:just saying|opening up) ",
|
||||||
|
r"\bThey gave more detail\b[^.]*\.",
|
||||||
|
]
|
||||||
|
_META_RE = re.compile("|".join(_META_PATTERNS), re.IGNORECASE)
|
||||||
|
|
||||||
|
# Stray control words left behind after a mangled tag is stripped.
|
||||||
|
# Any leftover angle-bracket control construct, tolerant of truncation/mangles
|
||||||
|
# (e.g. `</decide` with no closing `>`, or `<deside speak>`).
|
||||||
|
_TAG_RE = re.compile(r"</?\s*[a-z_][^>]*>?", re.IGNORECASE)
|
||||||
|
|
||||||
|
# Stray control words left behind after a mangled tag is stripped (used inline
|
||||||
|
# in _clean_text as leading/trailing guards).
|
||||||
|
_CONTROL_WORDS = r"(?:speak|stop|silent|done|response|known|unknown|precise)"
|
||||||
|
|
||||||
|
# Collapse runs of whitespace.
|
||||||
|
_MULTI_SPACE_RE = re.compile(r"\s{2,}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. Normalization -- turn messy real-world tokens into canonical tags so the
|
||||||
|
# structured parser can run cleanly.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _normalize(raw: str) -> str:
|
||||||
|
t = raw or ""
|
||||||
|
|
||||||
|
# 3a. Drop Qwen / decoded-token litter.
|
||||||
|
for junk in _JUNK_TOKENS:
|
||||||
|
t = t.replace(junk, "")
|
||||||
|
|
||||||
|
# 3b. Decide tags. Canonical is ``<decide>`` / ``</decide>`` but tokenization
|
||||||
|
# can drop a letter (<deside>) or add spaces, and the close can be
|
||||||
|
# truncated (``</decide``). ``de[cs]ide`` catches both spellings.
|
||||||
|
t = re.sub(
|
||||||
|
r"<\s*/?\s*de[cs]ide\b[^>]*>?>?",
|
||||||
|
lambda m: "</decide>" if "/" in m.group(0) else "<decide>",
|
||||||
|
t,
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3c. Verify tags -- same tolerance.
|
||||||
|
t = re.sub(
|
||||||
|
r"<\s*/?\s*verify\b[^>]*>?>?",
|
||||||
|
lambda m: "</verify>" if "/" in m.group(0) else "<verify>",
|
||||||
|
t,
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3d. Stop tags -- ``<stop>``, ``<stop (truncated`` (no close), and a bare
|
||||||
|
# ``<stop`` at end-of-output. The optional ``>?>`` handles all of these.
|
||||||
|
t = re.sub(r"<\s*stop\b[^>]*>?>?", "<stop>", t, flags=re.IGNORECASE)
|
||||||
|
|
||||||
|
# 3e. Strip a leading noise run (``<stop> <begin> <sep>`` ...) that some
|
||||||
|
# outputs carry before any thinking/decide content. This <stop> is junk,
|
||||||
|
# not the Heartly end-of-turn marker (which is mid/trailing).
|
||||||
|
t = re.sub(
|
||||||
|
r"^\s*((?:<stop>|<begin>|<sep>)\s*)*", "", t, flags=re.IGNORECASE
|
||||||
|
).lstrip()
|
||||||
|
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. Value normalization helpers.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_DECIDE_VAL_RE = re.compile(r"\b(speak|stop|silent)\b", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _decide_value(raw_val: str) -> str:
|
||||||
|
"""Reduce a decide tag body to canonical ``speak`` / ``stop``."""
|
||||||
|
m = _DECIDE_VAL_RE.search(raw_val or "")
|
||||||
|
if m:
|
||||||
|
v = m.group(1).lower()
|
||||||
|
return "stop" if v == "silent" else v
|
||||||
|
return "" # unparseable
|
||||||
|
|
||||||
|
|
||||||
|
_VERIFY_VAL_RE = re.compile(r"\b(known|unknown)\b", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_value(raw_val: str) -> str:
|
||||||
|
m = _VERIFY_VAL_RE.search(raw_val or "")
|
||||||
|
return m.group(1).lower() if m else ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. Parsed turn + segment parser.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@dataclass
|
||||||
|
class ParsedTurn:
|
||||||
|
reasoning: str = ""
|
||||||
|
decide: str = "" # speak | stop | ""
|
||||||
|
verify: str = "" # known | unknown | ""
|
||||||
|
answer: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
# ``thinking {reasoning} response<decide>...`` -- the word "response" is
|
||||||
|
# OPTIONAL (the model drops it sometimes; seen in the 2026-08-01 log). The
|
||||||
|
# lookahead ``(?=<decide>|\\Z)`` bounds the reasoning block at the decide tag
|
||||||
|
# (or end of string) so the (non-greedy) body doesn't greedily swallow the
|
||||||
|
# answer, and it does NOT consume the decide opener (leaving it for the decide
|
||||||
|
# parser to read the decide value).
|
||||||
|
_THINK_RE = re.compile(
|
||||||
|
r"\bthinking\b\s*(.*?)(\s*\bresponse\b)?\s*(?=<decide>|\Z)",
|
||||||
|
re.DOTALL | re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# After normalization, tags are canonical.
|
||||||
|
_DECIDE_CLOSE_RE = re.compile(r"<decide>(.*?)</decide>", re.DOTALL | re.IGNORECASE)
|
||||||
|
_VERIFY_CLOSE_RE = re.compile(r"<verify>(.*?)</verify>", re.DOTALL | re.IGNORECASE)
|
||||||
|
# Tolerant open-tag grabs (for mangled/missing close).
|
||||||
|
_DECIDE_OPEN_RE = re.compile(r"<decide\b([^<]*)", re.IGNORECASE)
|
||||||
|
_VERIFY_OPEN_RE = re.compile(r"<verify\b([^<]*)", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_segment(segment: str) -> ParsedTurn:
|
||||||
|
"""Parse one turn (already split on ``<stop>``)."""
|
||||||
|
seg = segment.strip()
|
||||||
|
if not seg:
|
||||||
|
return ParsedTurn()
|
||||||
|
|
||||||
|
# --- thinking block (word "response" optional) ---
|
||||||
|
reasoning = ""
|
||||||
|
m = _THINK_RE.search(seg)
|
||||||
|
if m:
|
||||||
|
reasoning = (m.group(1) or "").strip()
|
||||||
|
seg = seg[m.end():]
|
||||||
|
|
||||||
|
# --- decide ---
|
||||||
|
decide = ""
|
||||||
|
m = _DECIDE_CLOSE_RE.search(seg)
|
||||||
|
if m:
|
||||||
|
decide = _decide_value(m.group(1))
|
||||||
|
seg = seg[m.end():]
|
||||||
|
else:
|
||||||
|
mo = _DECIDE_OPEN_RE.search(seg)
|
||||||
|
if mo:
|
||||||
|
decide = _decide_value(mo.group(1))
|
||||||
|
seg = seg[mo.end():]
|
||||||
|
|
||||||
|
# --- verify ---
|
||||||
|
verify = ""
|
||||||
|
m = _VERIFY_CLOSE_RE.search(seg)
|
||||||
|
if m:
|
||||||
|
verify = _verify_value(m.group(1))
|
||||||
|
seg = seg[m.end():]
|
||||||
|
else:
|
||||||
|
mo = _VERIFY_OPEN_RE.search(seg)
|
||||||
|
if mo:
|
||||||
|
verify = _verify_value(mo.group(1))
|
||||||
|
seg = seg[mo.end():]
|
||||||
|
|
||||||
|
# --- answer zone: everything left, up to any stray stop ---
|
||||||
|
answer = seg
|
||||||
|
sm = re.search(r"<stop", answer, re.IGNORECASE)
|
||||||
|
if sm:
|
||||||
|
answer = answer[: sm.start()]
|
||||||
|
|
||||||
|
# Default an unparseable decide to "speak" (the model is answering).
|
||||||
|
if not decide:
|
||||||
|
decide = "speak"
|
||||||
|
|
||||||
|
return ParsedTurn(
|
||||||
|
reasoning=reasoning,
|
||||||
|
decide=decide,
|
||||||
|
verify=verify,
|
||||||
|
answer=answer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_reply(raw: str) -> list[ParsedTurn]:
|
||||||
|
"""Parse raw model output into one or more turns (multi-turn aware)."""
|
||||||
|
text = _normalize(raw)
|
||||||
|
turns: list[ParsedTurn] = []
|
||||||
|
for segment in re.split(r"<stop>", text):
|
||||||
|
seg = segment.strip()
|
||||||
|
if not seg:
|
||||||
|
continue
|
||||||
|
# A real turn must carry a decide marker; stray fence/backtick noise
|
||||||
|
# emitted between repeated <stop> markers is skipped.
|
||||||
|
if not _DECIDE_OPEN_RE.search(seg):
|
||||||
|
continue
|
||||||
|
turns.append(_parse_segment(seg))
|
||||||
|
if not turns:
|
||||||
|
# No structured grammar found at all -- treat the whole output as one
|
||||||
|
# best-effort turn.
|
||||||
|
turns.append(_parse_segment(text))
|
||||||
|
return turns
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 6. Answer sanitizers.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _strip_meta(text: str) -> str:
|
||||||
|
"""Remove the model's reasoning self-talk from a piece of text."""
|
||||||
|
return _META_RE.sub("", text).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe(text: str) -> str:
|
||||||
|
"""Drop verbatim-repeated sentences, keep first occurrence."""
|
||||||
|
out, seen = [], set()
|
||||||
|
for sent in re.split(r"(?<=[.!?])\s+", text):
|
||||||
|
s = sent.strip()
|
||||||
|
if not s:
|
||||||
|
continue
|
||||||
|
key = s.lower()
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
out.append(s)
|
||||||
|
return " ".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_text(text: str) -> str:
|
||||||
|
"""Final pass on an answer zone: strip residual tags/junk, meta, dedupe.
|
||||||
|
|
||||||
|
Whitespace collapse and sentence de-duplication run on the PROSE regions
|
||||||
|
only -- fenced code blocks (``` ``` ```) are preserved verbatim, because
|
||||||
|
their indentation is significant and must not be collapsed to a single
|
||||||
|
space.
|
||||||
|
"""
|
||||||
|
t = text or ""
|
||||||
|
# Qwen litter first (never answer content).
|
||||||
|
for junk in _JUNK_TOKENS:
|
||||||
|
t = t.replace(junk, "")
|
||||||
|
# Fence out code blocks so their whitespace survives intact. re.split with
|
||||||
|
# a capturing group yields [prose, code, prose, code, ...] -- code blocks
|
||||||
|
# land at odd indices and are emitted untouched.
|
||||||
|
pieces = re.split(r"(```[^\n]*\n.*?```)", t, flags=re.DOTALL)
|
||||||
|
out = []
|
||||||
|
for i, chunk in enumerate(pieces):
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
if i % 2 == 1: # captured fenced block -> keep verbatim
|
||||||
|
# The model emits literal backslash-n / backslash-t as plain text in
|
||||||
|
# code (training artifact). Restore real newlines/tabs so multi-line
|
||||||
|
# code renders instead of collapsing to a single line.
|
||||||
|
chunk = chunk.replace(chr(92) + "n", chr(10)).replace(chr(92) + "t", chr(9))
|
||||||
|
out.append(chunk)
|
||||||
|
continue
|
||||||
|
# prose region
|
||||||
|
chunk = _TAG_RE.sub(" ", chunk)
|
||||||
|
chunk = re.sub(rf"^\s*{_CONTROL_WORDS}\b\s*", "", chunk, flags=re.IGNORECASE)
|
||||||
|
chunk = re.sub(rf"\s*{_CONTROL_WORDS}\s*$", "", chunk, flags=re.IGNORECASE)
|
||||||
|
chunk = _strip_meta(chunk)
|
||||||
|
chunk = _MULTI_SPACE_RE.sub(" ", chunk).strip()
|
||||||
|
chunk = _dedupe(chunk)
|
||||||
|
if chunk:
|
||||||
|
out.append(chunk)
|
||||||
|
return " ".join(out).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _answer_from_reasoning(reasoning: str) -> str:
|
||||||
|
"""Last-resort: if the answer zone is empty, try to harvest a real answer
|
||||||
|
from the reasoning block (e.g. 'The answer is X' phrasing). Returns "" when
|
||||||
|
the reasoning only holds placeholder / meta chatter -- in that case the
|
||||||
|
caller maps the empty answer to the silence sentinel ("...")."""
|
||||||
|
if not reasoning:
|
||||||
|
return ""
|
||||||
|
m = re.search(
|
||||||
|
r"(?:the answer is|answer is|it's|it is)\s*:?\s*(.+?)(?:\.\s*$|\.\s*<|$)",
|
||||||
|
reasoning,
|
||||||
|
re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
if m:
|
||||||
|
return _clean_text(m.group(1))
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 7. Resolution -- decide what the user actually sees.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _resolve_chat(turns: list[ParsedTurn]) -> str:
|
||||||
|
parts = []
|
||||||
|
for t in turns:
|
||||||
|
if t.decide == "stop":
|
||||||
|
parts.append("...")
|
||||||
|
continue
|
||||||
|
ans = _clean_text(t.answer)
|
||||||
|
if not ans and t.verify == "unknown":
|
||||||
|
parts.append(ans or "I don't have that information.")
|
||||||
|
continue
|
||||||
|
if not ans:
|
||||||
|
ans = _answer_from_reasoning(t.reasoning)
|
||||||
|
parts.append(ans if ans else "...")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_debug(turns):
|
||||||
|
parts = []
|
||||||
|
for t in turns:
|
||||||
|
ans = _clean_text(t.answer)
|
||||||
|
if not ans:
|
||||||
|
ans = _answer_from_reasoning(t.reasoning)
|
||||||
|
meta = " ".join(
|
||||||
|
f"[{k}={v}]" for k, v in (("decide", t.decide), ("verify", t.verify)) if v
|
||||||
|
)
|
||||||
|
shown = f"{ans} {meta}".strip() if ans else (meta or "(empty)")
|
||||||
|
parts.append(shown)
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
# 6b. Last-resort legacy cleaner -- used only if structured parsing leaves a
|
||||||
|
# grammar token in the chat output. Aggressively strips every angle-bracket
|
||||||
|
# construct + control word, guaranteeing a clean result.
|
||||||
|
def _legacy_clean(text):
|
||||||
|
t = _normalize(text)
|
||||||
|
t = re.split(r"<stop", t, maxsplit=1)[0]
|
||||||
|
dm = _DECIDE_OPEN_RE.search(t)
|
||||||
|
if dm:
|
||||||
|
t = t[dm.start():]
|
||||||
|
else:
|
||||||
|
m = _THINK_RE.search(t)
|
||||||
|
if m:
|
||||||
|
t = t[m.end():] if m.end() <= len(t) else ""
|
||||||
|
t = _DECIDE_CLOSE_RE.sub(" ", t)
|
||||||
|
t = _VERIFY_CLOSE_RE.sub(" ", t)
|
||||||
|
t = _TAG_RE.sub(" ", t)
|
||||||
|
t = re.sub(rf"^\s*{_CONTROL_WORDS}\b\s*", "", t, flags=re.IGNORECASE)
|
||||||
|
t = re.sub(rf"\s*{_CONTROL_WORDS}\b\s*$", "", t, flags=re.IGNORECASE)
|
||||||
|
return _clean_text(t)
|
||||||
|
|
||||||
|
|
||||||
|
# 8. Public API
|
||||||
|
def format_reply(raw, mode="chat"):
|
||||||
|
"""Format a raw Heartly model output for display.
|
||||||
|
|
||||||
|
mode: "chat" (default), "debug", or "raw".
|
||||||
|
"""
|
||||||
|
if mode == "raw":
|
||||||
|
return raw if raw else ""
|
||||||
|
if mode not in ("chat", "debug"):
|
||||||
|
mode = "chat"
|
||||||
|
if not raw or not raw.strip():
|
||||||
|
return ""
|
||||||
|
|
||||||
|
turns = parse_reply(raw)
|
||||||
|
if mode == "debug":
|
||||||
|
return _resolve_debug(turns)
|
||||||
|
out = _resolve_chat(turns)
|
||||||
|
# Leak guarantee: if any grammar token survived, fall back hard.
|
||||||
|
if out == "" and raw.strip():
|
||||||
|
out = _legacy_clean(raw)
|
||||||
|
elif out != "" and _TAG_RE.search(out):
|
||||||
|
out = _legacy_clean(raw) or out
|
||||||
|
# Safety net: the model intermittently emits code (especially un-fenced) as
|
||||||
|
# literal backslash-n / backslash-t text. Render those as real newlines/tabs
|
||||||
|
# so answers never collapse to a single line in the browser or CLI.
|
||||||
|
out = out.replace(chr(92) + "n", chr(10)).replace(chr(92) + "t", chr(9))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def clean_reply(text):
|
||||||
|
"""Legacy-compatible drop-in replacement for the old clean_reply()."""
|
||||||
|
return format_reply(text, mode="chat")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
sample = sys.stdin.read() if not sys.argv[1:] else open(sys.argv[1]).read()
|
||||||
|
print(format_reply(sample, mode="debug"))
|
||||||
10
requirements.txt
Normal file
10
requirements.txt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
torch>=2.0.0
|
||||||
|
transformers>=4.37.0
|
||||||
|
datasets>=2.14.0
|
||||||
|
scikit-learn>=1.0
|
||||||
|
sentencepiece>=0.1.99
|
||||||
|
accelerate>=0.25.0
|
||||||
|
numpy>=1.24
|
||||||
|
fastapi>=0.110.0
|
||||||
|
uvicorn>=0.29.0
|
||||||
|
huggingface_hub>=0.25
|
||||||
133
server.py
Normal file
133
server.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""server.py - HTTP server for heartly-qwen-code-v3.
|
||||||
|
|
||||||
|
Serves a browser chat UI at / plus JSON endpoints /health and /chat.
|
||||||
|
The model's <decide>/<verify>/<stop>/<thinking> scaffolding is stripped by
|
||||||
|
reply_formatter (same-directory module) before the answer reaches the user.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import torch
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from transformers import AutoModelForCausalLM, AutoTokenizer
|
||||||
|
|
||||||
|
from reply_formatter import format_reply
|
||||||
|
|
||||||
|
|
||||||
|
class ChatRequest(BaseModel):
|
||||||
|
prompt: str
|
||||||
|
max_new_tokens: int = 512
|
||||||
|
temperature: float = 0.7
|
||||||
|
top_p: float = 0.9
|
||||||
|
do_sample: bool = True
|
||||||
|
mode: str = "chat" # chat | debug | raw
|
||||||
|
|
||||||
|
|
||||||
|
class _State:
|
||||||
|
model: Optional[object] = None
|
||||||
|
tokenizer: Optional[object] = None
|
||||||
|
model_name: str = os.environ.get("HEARTLY_MODEL", "eivintobias/heartly-qwen-code")
|
||||||
|
lock: threading.Lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _format_prompt(prompt: str) -> str:
|
||||||
|
return f"User: {prompt}\nAssistant: "
|
||||||
|
|
||||||
|
|
||||||
|
def _load() -> None:
|
||||||
|
if _State.model is not None:
|
||||||
|
return
|
||||||
|
with _State.lock:
|
||||||
|
if _State.model is None:
|
||||||
|
dtype = torch.float16 if torch.cuda.is_available() else torch.float32
|
||||||
|
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||||
|
tok = AutoTokenizer.from_pretrained(_State.model_name)
|
||||||
|
model = AutoModelForCausalLM.from_pretrained(_State.model_name, torch_dtype=dtype, device_map=device)
|
||||||
|
model.eval()
|
||||||
|
_State.tokenizer, _State.model = tok, model
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Heartly Qwen-Code v3", version="3.0")
|
||||||
|
|
||||||
|
|
||||||
|
CHAT_HTML = """<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Heartly Qwen-Code v3</title>
|
||||||
|
<style>
|
||||||
|
html,body{margin:0;height:100%;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:#0b0f14;color:#e6e8ec}
|
||||||
|
#wrap{max-width:820px;height:100vh;margin:0 auto;display:flex;flex-direction:column}
|
||||||
|
#msgs{flex:1;overflow-y:auto;padding:18px 16px 14px;display:flex;flex-direction:column;gap:12px}
|
||||||
|
.msg{max-width:82%;white-space:pre-wrap;line-height:1.45;padding:10px 14px;border-radius:14px;font-size:14px}
|
||||||
|
.user{background:#1f2430;margin-left:auto;border-radius:16px 4px 16px 16px}
|
||||||
|
.bot{background:#171b23;margin-right:auto;border-radius:4px 16px 16px 16px}
|
||||||
|
.bot.placeholder{opacity:.55}
|
||||||
|
#form{display:flex;gap:8px;padding:12px;background:#0f131a;border-top:1px solid #1d222c}
|
||||||
|
#input{flex:1;background:#171b23;border:1px solid #2a2f3c;border-radius:10px;color:#e6e8ec;padding:10px 12px;font-size:14px;outline:none}
|
||||||
|
#input::placeholder{color:#7a8190}
|
||||||
|
button{background:#2f6fec;border:none;color:#fff;border-radius:10px;padding:10px 16px;cursor:pointer;font-size:14px}
|
||||||
|
button:disabled{opacity:.5;cursor:not-allowed}
|
||||||
|
</style></head><body>
|
||||||
|
<div id="wrap"><div id="msgs"></div>
|
||||||
|
<form id="form" autocomplete="off">
|
||||||
|
<input id="input" placeholder="Ask the Heartly Qwen-Code v3 model..." autofocus>
|
||||||
|
<button id="send">Send</button>
|
||||||
|
</form></div>
|
||||||
|
<script>
|
||||||
|
const msgs=document.getElementById('msgs'),form=document.getElementById('form'),input=document.getElementById('input'),btn=document.getElementById('send');
|
||||||
|
function addMsg(c,t,ph){const d=document.createElement('div');d.className='msg '+c;if(ph)d.classList.add('placeholder');d.textContent=t;msgs.appendChild(d);msgs.scrollTop=msgs.scrollHeight;return d;}
|
||||||
|
function busy(b){btn.disabled=b;input.disabled=b;}
|
||||||
|
form.onsubmit=function(e){e.preventDefault();const p=input.value.trim();if(!p||btn.disabled)return;
|
||||||
|
addMsg('user',p);const bot=addMsg('bot','thinking...',true);busy(true);input.value='';
|
||||||
|
fetch('/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt:p,max_new_tokens:256,temperature:0.3,top_p:0.9,mode:'chat'})})
|
||||||
|
.then(r=>r.json()).then(d=>{bot.classList.remove('placeholder');bot.textContent=d.reply||'(no reply)';})
|
||||||
|
.catch(err=>{bot.classList.remove('placeholder');bot.textContent='error: '+err;}).finally(()=>{busy(false);input.focus();});};
|
||||||
|
</script></body></html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
async def chat_page():
|
||||||
|
"""Browser chat UI - open the tab and start typing."""
|
||||||
|
return CHAT_HTML
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
@torch.no_grad()
|
||||||
|
async def health():
|
||||||
|
return {"status": "ready" if _State.model is not None else "loading (loads on first /chat)", "model": _State.model_name}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chat")
|
||||||
|
@torch.no_grad()
|
||||||
|
async def chat(req: ChatRequest):
|
||||||
|
_load()
|
||||||
|
model, tok = _State.model, _State.tokenizer
|
||||||
|
device = next(model.parameters()).device
|
||||||
|
ids = tok.encode(_format_prompt(req.prompt), return_tensors="pt").to(device)
|
||||||
|
out = model.generate(ids, max_new_tokens=req.max_new_tokens, temperature=req.temperature, top_p=req.top_p, do_sample=req.do_sample, pad_token_id=tok.eos_token_id)
|
||||||
|
raw = tok.decode(out[0][ids.shape[1]:], skip_special_tokens=False)
|
||||||
|
reply = format_reply(raw, mode=req.mode)
|
||||||
|
return {"model": _State.model_name, "raw": raw, "reply": reply}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p = argparse.ArgumentParser(description="Heartly Qwen-Code v3 HTTP server")
|
||||||
|
p.add_argument("--model", default=os.environ.get("HEARTLY_MODEL", "eivintobias/heartly-qwen-code"))
|
||||||
|
p.add_argument("--host", default="127.0.0.1")
|
||||||
|
p.add_argument("--port", type=int, default=8000)
|
||||||
|
a = p.parse_args()
|
||||||
|
_State.model_name = a.model
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run("server:app", host=a.host, port=a.port)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
3
tokenizer.json
Normal file
3
tokenizer.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:3fd169731d2cbde95e10bf356d66d5997fd885dd8dbb6fb4684da3f23b2585d8
|
||||||
|
size 11421892
|
||||||
30
tokenizer_config.json
Normal file
30
tokenizer_config.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"add_prefix_space": false,
|
||||||
|
"backend": "tokenizers",
|
||||||
|
"bos_token": null,
|
||||||
|
"clean_up_tokenization_spaces": false,
|
||||||
|
"eos_token": "<|endoftext|>",
|
||||||
|
"errors": "replace",
|
||||||
|
"extra_special_tokens": [
|
||||||
|
"<|im_start|>",
|
||||||
|
"<|im_end|>",
|
||||||
|
"<|object_ref_start|>",
|
||||||
|
"<|object_ref_end|>",
|
||||||
|
"<|box_start|>",
|
||||||
|
"<|box_end|>",
|
||||||
|
"<|quad_start|>",
|
||||||
|
"<|quad_end|>",
|
||||||
|
"<|vision_start|>",
|
||||||
|
"<|vision_end|>",
|
||||||
|
"<|vision_pad|>",
|
||||||
|
"<|image_pad|>",
|
||||||
|
"<|video_pad|>"
|
||||||
|
],
|
||||||
|
"is_local": false,
|
||||||
|
"local_files_only": false,
|
||||||
|
"model_max_length": 32768,
|
||||||
|
"pad_token": "<|endoftext|>",
|
||||||
|
"split_special_tokens": false,
|
||||||
|
"tokenizer_class": "Qwen2Tokenizer",
|
||||||
|
"unk_token": null
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user