aurelien commited on
Commit
21f5d8a
·
0 Parent(s):

1st commit

Browse files
Files changed (5) hide show
  1. Dockerfile +34 -0
  2. README.md +39 -0
  3. app/main.py +223 -0
  4. model_downloader.py +6 -0
  5. requirements.txt +6 -0
Dockerfile ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Image légère Python
2
+ FROM python:3.11-slim
3
+
4
+ ENV PYTHONDONTWRITEBYTECODE=1 \
5
+ PYTHONUNBUFFERED=1 \
6
+ PIP_NO_CACHE_DIR=1 \
7
+ HF_HOME=/root/.cache/huggingface
8
+
9
+ # Déps système minimales (certs, locales, build basique)
10
+ RUN apt-get update && apt-get install -y --no-install-recommends \
11
+ build-essential curl ca-certificates git \
12
+ && rm -rf /var/lib/apt/lists/*
13
+
14
+ WORKDIR /app
15
+
16
+ # Fichiers app
17
+ COPY requirements.txt /app/requirements.txt
18
+ RUN pip install --upgrade pip \
19
+ && pip install -r /app/requirements.txt
20
+
21
+ # Copier le code
22
+ COPY app /app/app
23
+
24
+ # (Optionnel) Pré-télécharger le modèle au build pour accélérer le premier run
25
+ RUN ls -la
26
+ COPY model_downloader.py /app/model_downloader.py
27
+ RUN python /app/model_downloader.py
28
+
29
+ # HF Spaces: écouter sur $PORT (par défaut 7860)
30
+ ENV PORT=7860
31
+ EXPOSE 7860
32
+
33
+ # Lancer l'app
34
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Park4night Reviews Summarizer (FastAPI)
2
+
3
+ FastAPI pour résumer **une liste d'avis** (multi-avis) avec stratégie hiérarchique.
4
+ Conçu pour Space Docker Hugging Face (écoute sur `$PORT`, default 7860).
5
+
6
+ ## Build Docker
7
+
8
+ ```
9
+ docker build -t park4night-summarizer . --progress=plain
10
+ ```
11
+
12
+ ## Run Docker Container
13
+
14
+ ```
15
+ docker run -d -p 7860:7860 --name p4n-ai park4night-summarizer
16
+ ```
17
+
18
+
19
+
20
+
21
+ ## Endpoints
22
+
23
+ - `GET /health`
24
+ - `POST /summarize-list`
25
+ Body:
26
+ ```json
27
+ {
28
+ "reviews": ["avis 1", "avis 2", "..."],
29
+ "group_size": 5,
30
+ "partial_target_ratio": 0.5,
31
+ "final_target_ratio": 0.6,
32
+ "num_beams": 5
33
+ }
34
+ -`GET /summarize-place?place_id=645109&lang=fr`
35
+ Params
36
+ ```
37
+ place_id='645109'
38
+ lang='fr'
39
+ ```
app/main.py ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import warnings
3
+ from typing import List, Optional
4
+
5
+ import torch
6
+ import httpx
7
+ from fastapi import FastAPI, HTTPException, Query
8
+ from fastapi.middleware.cors import CORSMiddleware
9
+ from pydantic import BaseModel
10
+ from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
11
+
12
+ # ——— Filtrer quelques warnings bruyants mais bénins ———
13
+ warnings.filterwarnings("ignore", message="To copy construct from a tensor", category=UserWarning)
14
+ warnings.filterwarnings("ignore", message="Unfeasible length constraints", category=UserWarning)
15
+
16
+ # ——— Config ———
17
+ MODEL_NAME = os.getenv("MODEL_NAME", "facebook/bart-large-cnn")
18
+ TOKENIZER_MAX_LEN = int(os.getenv("TOKENIZER_MAX_LEN", "1024"))
19
+ PORT = int(os.getenv("PORT", "7860"))
20
+ P4N_COMM_URL = "https://park4night.com/services/V4.1/commGet.php"
21
+ P4N_TRAD_URL = "https://park4night.com/services/V4.1/commGetTrad_cors.php"
22
+ HTTP_TIMEOUT = float(os.getenv("HTTP_TIMEOUT", "15"))
23
+
24
+ def get_device():
25
+ if torch.cuda.is_available():
26
+ return torch.device("cuda")
27
+ # MPS utile en dev local sur Mac ; côté HF Spaces Docker tu seras sur CPU ou GPU CUDA
28
+ if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
29
+ return torch.device("mps")
30
+ return torch.device("cpu")
31
+
32
+ DEVICE = get_device()
33
+ torch.set_num_threads(int(os.getenv("TORCH_NUM_THREADS", "1")))
34
+
35
+ # ——— Chargement modèle/tokenizer au démarrage ———
36
+ tokenizer = AutoTokenizer.from_pretrained(
37
+ MODEL_NAME,
38
+ model_max_length=TOKENIZER_MAX_LEN,
39
+ truncation_side="right",
40
+ )
41
+ model = AutoModelForSeq2SeqLM.from_pretrained(MODEL_NAME).to(DEVICE)
42
+
43
+ # ——— FastAPI ———
44
+ app = FastAPI(title="Park4night Reviews Summarizer", version="1.0.0")
45
+ app.add_middleware(
46
+ CORSMiddleware,
47
+ allow_origins=["*"], allow_credentials=True,
48
+ allow_methods=["*"], allow_headers=["*"],
49
+ )
50
+
51
+ # ——— Schemas ———
52
+ class SummarizeListRequest(BaseModel):
53
+ reviews: List[str]
54
+ group_size: Optional[int] = 5
55
+ partial_target_ratio: Optional[float] = 0.5
56
+ final_target_ratio: Optional[float] = 0.6
57
+ max_new_cap_partial: Optional[int] = 180
58
+ max_new_cap_final: Optional[int] = 220
59
+ num_beams: Optional[int] = 5
60
+
61
+ class SummarizeListResponse(BaseModel):
62
+ summary: str
63
+ partial_summaries: Optional[List[str]] = None
64
+
65
+ # ——— Utils ———
66
+ def _postprocess_sentence_end(text: str) -> str:
67
+ text = text.strip()
68
+ if not text:
69
+ return text
70
+ if text[-1] not in [".", "!", "?"]:
71
+ # tente de couper proprement à la dernière phrase
72
+ if "." in text:
73
+ text = text.rsplit(".", 1)[0] + "."
74
+ else:
75
+ text += "."
76
+ return text
77
+
78
+ def _generate_summary(inputs, max_new_tokens: int, num_beams: int = 5) -> str:
79
+ with torch.inference_mode():
80
+ summary_ids = model.generate(
81
+ **inputs,
82
+ max_new_tokens=max_new_tokens,
83
+ do_sample=False,
84
+ num_beams=num_beams,
85
+ length_penalty=1.1,
86
+ early_stopping=False,
87
+ no_repeat_ngram_size=3,
88
+ )
89
+ text = tokenizer.decode(summary_ids[0], skip_special_tokens=True)
90
+ return _postprocess_sentence_end(text)
91
+
92
+ def _prepare_inputs(text: str):
93
+ return tokenizer(
94
+ text,
95
+ return_tensors="pt",
96
+ truncation=True,
97
+ max_length=TOKENIZER_MAX_LEN,
98
+ ).to(DEVICE)
99
+
100
+ def _summarize_chunk(
101
+ text: str,
102
+ target_ratio: float = 0.5,
103
+ max_new_cap: int = 180,
104
+ num_beams: int = 5,
105
+ ) -> str:
106
+ inputs = _prepare_inputs(text)
107
+ in_tokens = inputs["input_ids"].shape[1]
108
+ est_new = max(40, int(in_tokens * target_ratio))
109
+ max_new_tokens = min(max_new_cap, est_new)
110
+ return _generate_summary(inputs, max_new_tokens=max_new_tokens, num_beams=num_beams)
111
+
112
+ async def fetch_reviews(place_id: int, lang: str = "fr") -> List[str]:
113
+ """
114
+ Récupère les avis d'un lieu Park4night et les traduit via l'endpoint interne (context_lang).
115
+ Appels asynchrones pour accélérer.
116
+ """
117
+ async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
118
+ r = await client.get(P4N_COMM_URL, params={"lieu_id": place_id})
119
+ r.raise_for_status()
120
+ data = r.json()
121
+ comments = data.get("commentaires", [])
122
+ if not comments:
123
+ return []
124
+
125
+ # paralléliser les traductions
126
+ async def _get_trad(cid: int):
127
+ tr = await client.get(P4N_TRAD_URL, params={"id_comm": cid, "context_lang": lang})
128
+ tr.raise_for_status()
129
+ return tr.json().get("translation", "")
130
+
131
+ tasks = [_get_trad(c["id"]) for c in comments if "id" in c]
132
+ translations = await asyncio_gather_limited(tasks, limit=10)
133
+ return [t for t in translations if t and t.strip()]
134
+
135
+ async def asyncio_gather_limited(tasks, limit: int = 10):
136
+ """
137
+ Regroupe des coroutines avec un parallélisme limité.
138
+ """
139
+ import asyncio
140
+ semaphore = asyncio.Semaphore(limit)
141
+ async def sem_task(coro):
142
+ async with semaphore:
143
+ return await coro
144
+ return await asyncio.gather(*[sem_task(t) for t in tasks])
145
+
146
+ def summarize_reviews(
147
+ reviews: List[str],
148
+ group_size: int = 5,
149
+ partial_target_ratio: float = 0.5,
150
+ final_target_ratio: float = 0.6,
151
+ max_new_cap_partial: int = 180,
152
+ max_new_cap_final: int = 220,
153
+ num_beams: int = 5,
154
+ ) -> SummarizeListResponse:
155
+ if not reviews:
156
+ return SummarizeListResponse(summary="", partial_summaries=[])
157
+
158
+ partial_summaries: List[str] = []
159
+ # Étape 1 : résumés partiels
160
+ for i in range(0, len(reviews), group_size):
161
+ group_text = "\n".join(reviews[i : i + group_size])
162
+ partial = _summarize_chunk(
163
+ group_text,
164
+ target_ratio=partial_target_ratio,
165
+ max_new_cap=max_new_cap_partial,
166
+ num_beams=num_beams,
167
+ )
168
+ partial_summaries.append(partial)
169
+
170
+ # Étape 2 : résumé global
171
+ combined = " ".join(partial_summaries)
172
+ final = _summarize_chunk(
173
+ combined,
174
+ target_ratio=final_target_ratio,
175
+ max_new_cap=max_new_cap_final,
176
+ num_beams=num_beams,
177
+ )
178
+
179
+ return SummarizeListResponse(summary=final, partial_summaries=None)
180
+
181
+ # ——— Endpoints ———
182
+ @app.get("/health")
183
+ def health():
184
+ return {"status": "ok", "model": MODEL_NAME, "device": str(DEVICE)}
185
+
186
+ @app.post("/summarize-list", response_model=SummarizeListResponse)
187
+ def summarize_list(body: SummarizeListRequest):
188
+ if not body.reviews:
189
+ raise HTTPException(400, "reviews is empty")
190
+ return summarize_reviews(
191
+ reviews=body.reviews,
192
+ group_size=body.group_size,
193
+ partial_target_ratio=body.partial_target_ratio,
194
+ final_target_ratio=body.final_target_ratio,
195
+ max_new_cap_partial=body.max_new_cap_partial,
196
+ max_new_cap_final=body.max_new_cap_final,
197
+ num_beams=body.num_beams,
198
+ )
199
+
200
+ @app.get("/summarize-place", response_model=SummarizeListResponse)
201
+ async def summarize_place(
202
+ place_id: int = Query(..., description="park4night lieu_id"),
203
+ lang: str = Query("fr", description="langue cible (ex: fr, en, es, de, it, nl)"),
204
+ group_size: int = 5,
205
+ partial_target_ratio: float = 0.5,
206
+ final_target_ratio: float = 0.6,
207
+ num_beams: int = 5,
208
+ ):
209
+ reviews = await fetch_reviews(place_id, lang=lang)
210
+ if not reviews:
211
+ raise HTTPException(404, f"Aucun avis pour lieu_id={place_id}")
212
+ return summarize_reviews(
213
+ reviews=reviews,
214
+ group_size=group_size,
215
+ partial_target_ratio=partial_target_ratio,
216
+ final_target_ratio=final_target_ratio,
217
+ num_beams=num_beams,
218
+ )
219
+
220
+ # ——— Lancement local (utile hors Spaces) ———
221
+ if __name__ == "__main__":
222
+ import uvicorn
223
+ uvicorn.run("app.main:app", host="0.0.0.0", port=PORT, reload=False, workers=1)
model_downloader.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ from transformers import AutoTokenizer, AutoModelForSeq2SeqLM; \
2
+ import os; \
3
+ name=os.getenv("MODEL_NAME","facebook/bart-large-cnn"); \
4
+ tok=AutoTokenizer.from_pretrained(name); \
5
+ _ = AutoModelForSeq2SeqLM.from_pretrained(name); \
6
+ print("Model cached:", name)
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ fastapi==0.115.5
2
+ uvicorn[standard]==0.32.0
3
+ httpx==0.27.2
4
+ transformers==4.44.2
5
+ accelerate==0.34.2
6
+ torch