sanchitttt/Precision_Coder
0
1import os2import json3import time4import requests5import pandas as pd6from flask import Flask, request, jsonify, send_from_directory7from werkzeug.utils import secure_filename8 9app = Flask(__name__)10 11OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")12 13# ─────────────────────────────────────────────14# FRONTEND15# ─────────────────────────────────────────────16@app.route("/")17def home():18 return send_from_directory(".", "index.html")19 20 21# ─────────────────────────────────────────────22# FILE READER23# ─────────────────────────────────────────────24@app.route('/read-file', methods=['POST'])25def read_file():26 if 'file' not in request.files:27 return jsonify({'error': 'No file uploaded'}), 40028 29 file = request.files['file']30 filename = secure_filename(file.filename)31 32 try:33 if filename.endswith('.xlsx'):34 df = pd.read_excel(file, keep_default_na=False, na_values=[''])35 elif filename.endswith('.csv'):36 df = pd.read_csv(file, keep_default_na=False, na_values=[''])37 else:38 return jsonify({'error': 'Only CSV and XLSX files are supported'}), 40039 40 cols_lower = [str(c).lower().strip() for c in df.columns]41 42 def find_col(candidates):43 for p in candidates:44 if p in cols_lower:45 return df.columns[cols_lower.index(p)]46 return None47 48 resp_col = find_col(['response','responses','verbatim','verbatims',49 'answer','answers','openend','open_end','open end',50 'oe','text','comment','comments','mention']) or df.columns[0]51 country_col = find_col(['country','market','region','geo','geography','nation'])52 id_col = find_col(['id','resp_id','respondent_id','respondentid',53 'uid','uuid','caseid','case_id','rid'])54 55 keep = [c for c in [id_col, resp_col, country_col] if c is not None]56 sub = df[keep].copy()57 sub[resp_col] = sub[resp_col].astype(str).str.strip()58 sub = sub[~sub[resp_col].isin(['', 'nan', 'NaN'])]59 60 if len(sub) > 10000:61 return jsonify({'error': 'Maximum 10,000 responses per upload'}), 40062 63 records = []64 for _, row in sub.iterrows():65 rec = {'response': str(row[resp_col])}66 if id_col: rec['id'] = str(row[id_col])67 if country_col: rec['country'] = str(row[country_col])68 records.append(rec)69 70 return jsonify({71 'records': records,72 'has_country': country_col is not None,73 'has_id': id_col is not None,74 'total': len(records)75 })76 77 except Exception as e:78 return jsonify({'error': str(e)}), 50079 80 81# ─────────────────────────────────────────────82# OPENAI CALL (single shared function)83# Returns (content_string, error_string)84# ─────────────────────────────────────────────85def call_openai(messages, max_tokens=4000):86 if not OPENAI_API_KEY:87 return None, "OPENAI_API_KEY is not set"88 89 for attempt in range(4):90 try:91 resp = requests.post(92 "https://api.openai.com/v1/chat/completions",93 headers={94 "Authorization": f"Bearer {OPENAI_API_KEY}",95 "Content-Type": "application/json"96 },97 json={98 "model": "gpt-4o-mini",99 "messages": messages,100 "temperature": 0.1,101 "max_tokens": max_tokens102 # NOTE: no response_format — avoids extra overhead103 # and works fine because we parse flexibly104 },105 timeout=120106 )107 108 if resp.status_code == 429:109 wait = int(resp.headers.get("Retry-After", 20))110 time.sleep(min(wait, 60))111 continue112 113 result = resp.json()114 115 if resp.status_code != 200:116 err_msg = result.get("error", {}).get("message", str(result))117 return None, f"OpenAI error {resp.status_code}: {err_msg}"118 119 content = (result.get("choices") or [{}])[0] \120 .get("message", {}) \121 .get("content", "")122 123 return content.strip(), None124 125 except requests.exceptions.Timeout:126 if attempt == 3:127 return None, "Request timed out after 120s — try a smaller batch size"128 time.sleep(5)129 130 except Exception as e:131 if attempt == 3:132 return None, str(e)133 time.sleep(3)134 135 return None, "Max retries exceeded"136 137 138# ─────────────────────────────────────────────139# CHAT (main coding endpoint)140# ─────────────────────────────────────────────141@app.route("/chat", methods=["POST"])142def chat():143 try:144 data = request.json or {}145 content, err = call_openai(data.get("messages", []))146 147 if err:148 return jsonify({"error": err}), 500149 150 # Return in the shape the frontend expects151 return jsonify({"choices": [{"message": {"content": content}}]})152 153 except Exception as e:154 return jsonify({"error": str(e)}), 500155 156 157# ─────────────────────────────────────────────158# REVIEW (self-correction pass)159# ─────────────────────────────────────────────160@app.route("/review", methods=["POST"])161def review():162 try:163 data = request.json or {}164 to_review = data.get("items", [])165 codebook_text = data.get("codebook_text", "")166 question = data.get("question", "")167 catch_all_names = data.get("catch_all_names", ["other"])168 169 if not to_review:170 return jsonify({"results": []})171 172 catch_list = ", ".join(f'"{n}"' for n in catch_all_names)173 q_line = f'Survey question: "{question}"\n' if question else ""174 175 system = (176 "You are reviewing potentially wrong open-end coding decisions.\n"177 + q_line178 + "CODING FRAME:\n" + codebook_text + "\n\n"179 + f"For each item: if the coder's reason shows they identified a specific match "180 f"but still assigned a catch-all code ({catch_list}), correct it. "181 "Only keep the catch-all when nothing in the frame genuinely fits.\n\n"182 "Return ONLY a JSON array (no markdown):\n"183 '[{"index":0,"response":"...","corrected":"...","codes":["Name"],"ids":["ID"],"reason":"...","confidence":"HIGH"}]'184 )185 186 lines = [187 f'{i}. response="{r["response"]}" | corrected="{r.get("corrected","")}" | '188 f'coded_as="{",".join(r.get("codes",[]))}" | reason="{r.get("reason","")}"'189 for i, r in enumerate(to_review)190 ]191 192 content, err = call_openai([193 {"role": "system", "content": system},194 {"role": "user", "content": f"Review {len(to_review)} items:\n" + "\n".join(lines)}195 ])196 197 if err:198 return jsonify({"error": err}), 500199 200 # Parse array from response201 clean = content.replace("```json","").replace("```","").strip()202 try:203 parsed = json.loads(clean)204 except Exception:205 m = clean.find('[')206 if m != -1:207 parsed = json.loads(clean[m:clean.rfind(']')+1])208 else:209 parsed = []210 211 if isinstance(parsed, dict):212 parsed = parsed.get("results", list(parsed.values())[0] if parsed else [])213 214 # Ensure index field exists215 for i, item in enumerate(parsed):216 if "index" not in item:217 item["index"] = to_review[i].get("_review_index", i) if i < len(to_review) else i218 219 return jsonify({"results": parsed})220 221 except Exception as e:222 return jsonify({"error": str(e)}), 500223 224 225# ─────────────────────────────────────────────226# BUILD-PROMPT (kept for compatibility)227# ─────────────────────────────────────────────228@app.route("/build-prompt", methods=["POST"])229def build_prompt():230 # Prompt is now built client-side; this is a no-op kept for safety231 data = request.json or {}232 return jsonify({"prompt": "", "question": data.get("question", "")})233 234 235# ─────────────────────────────────────────────236# RUN237# ─────────────────────────────────────────────238if __name__ == "__main__":239 app.run(host="0.0.0.0", port=7860)240 