triflix commited on
Commit
57f2d25
·
verified ·
1 Parent(s): f199334

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +328 -252
app.py CHANGED
@@ -1,252 +1,328 @@
1
- import os
2
- import shutil
3
- import json
4
- import pandas as pd
5
- import base64
6
- from google import genai
7
- from google.genai import types
8
- from fastapi import FastAPI, UploadFile, File, HTTPException
9
- from typing import List
10
-
11
- app = FastAPI()
12
-
13
- # Define a temporary directory for file storage
14
- TMP_DIR = "/tmp/fastapi_files"
15
-
16
- @app.on_event("startup")
17
- async def startup_event():
18
- """Create the temporary directory on startup if it doesn't exist."""
19
- os.makedirs(TMP_DIR, exist_ok=True)
20
-
21
- @app.on_event("shutdown")
22
- async def shutdown_event():
23
- """Clean up the temporary directory on shutdown."""
24
- if os.path.exists(TMP_DIR):
25
- shutil.rmtree(TMP_DIR)
26
-
27
- def load_file(path: str):
28
- ext = os.path.splitext(path)[-1].lower()
29
- if ext == ".csv":
30
- df = pd.read_csv(path)
31
- elif ext in [".xls", ".xlsx"]:
32
- # For API, we cannot interactively ask for sheet number.
33
- # We'll assume the first sheet or require sheet_name as a parameter if needed.
34
- # For now, let's just load the first sheet.
35
- df = pd.read_excel(path, sheet_name=0)
36
- else:
37
- raise ValueError("Unsupported file type")
38
- return df.copy()
39
-
40
- def preprocess(df, drop_thresh=0.5):
41
- df = df.copy()
42
- df.columns = [str(c).strip().lower().replace(" ", "_") for c in df.columns]
43
- df = df.loc[:, df.isnull().mean() < drop_thresh]
44
- for col in df.columns:
45
- if pd.api.types.is_numeric_dtype(df[col]):
46
- df.loc[:, col] = df[col].fillna(df[col].median())
47
- elif pd.api.types.is_datetime64_any_dtype(df[col]):
48
- df.loc[:, col] = df[col].fillna(pd.Timestamp('1970-01-01'))
49
- else:
50
- df.loc[:, col] = df[col].fillna("Unknown")
51
- for col in df.columns:
52
- if df[col].dtype == 'object':
53
- try:
54
- df.loc[:, col] = pd.to_numeric(df[col])
55
- except:
56
- pass
57
- df = df.drop_duplicates()
58
- return df
59
-
60
- def metadata(df):
61
- return {
62
- "rows": df.shape[0],
63
- "columns": df.shape[1],
64
- "column_names": list(df.columns),
65
- "column_types": df.dtypes.astype(str).to_dict(),
66
- "unique_values": {col: df[col].nunique() for col in df.columns}
67
- }
68
-
69
- def generate_summary(meta, fiverow):
70
- client = genai.Client(api_key="AIzaSyDLa5cYGVVLVvKHuzBWVKJ-UtfQ7NgpRK0") # Use environment variable for API key
71
- model = "gemini-2.5-flash-lite"
72
-
73
- # direct structured system instruction enhanced with multiple layout templates
74
- system_prompt = """
75
- You are a strict JSON generator.
76
- Input contains:
77
- - meta: dataframe metadata
78
- - fiverow: first 5 records of dataframe
79
-
80
- You must output JSON with the following structure:
81
- {
82
- "summary": "<short natural language overview of dataset>",
83
- "recommended_charts": [
84
- {
85
- "type": "<one of: bar, pie, timeseries, histogram, scatter, multiple_columns, stacked_bar, heatmap>",
86
- "title": "<short title for chart>",
87
- "columns": ["<col1>", "<col2>", "..."],
88
- "python_code": "<full runnable Python code using seaborn/matplotlib that produces the chart>"
89
- },
90
- ...
91
- ]
92
- }
93
-
94
- Mandatory rules:
95
- - Always produce syntactically valid JSON ONLY. No text outside the JSON object.
96
- - Provide at least these chart types somewhere in recommended_charts: bar, pie, timeseries, histogram, scatter, multiple_columns, stacked_bar, heatmap.
97
- - Use only column names that appear in meta['column_names'].
98
- - The python_code string must be self-contained and runnable assuming a variable `df` exists containing the full cleaned DataFrame. Start the code with imports:
99
- import pandas as pd
100
- import seaborn as sns
101
- import matplotlib.pyplot as plt
102
- and include any necessary preprocessing steps (e.g., parsing dates).
103
- - For timeseries charts ensure the datetime column is parsed (`pd.to_datetime`) before plotting.
104
- - For multiple_columns provide a pairplot or facetgrid example that uses up to 4 numeric columns or sensible categorical splits.
105
- - For stacked_bar, show aggregation code (groupby + unstack) and plotting with df.plot(kind='bar', stacked=True).
106
- - For heatmap, compute correlation matrix and plot sns.heatmap with annotations.
107
- - For pie charts, ensure grouping/aggregation when there are >20 unique categories (group small categories into 'Other').
108
- - For histogram and scatter include axis labels and tight_layout; include plt.show() at the end.
109
- - Keep code minimal but complete so a user can copy-paste and run (assume seaborn, matplotlib, pandas installed).
110
- - For each chart add a sensible "columns" list showing which columns the code uses.
111
- - Do not include examples using columns not present in meta.
112
- - Do not include more than 10 recommended_charts.
113
- - Ensure strings inside the JSON are escaped properly so the JSON parses.
114
-
115
- Produce concise natural-language one-line summary in "summary". Ensure JSON is parseable by json.loads in Python.
116
- """
117
-
118
- user_prompt = {
119
- "meta": meta,
120
- "fiverow": fiverow
121
- }
122
-
123
- contents = [
124
- types.Content(
125
- role="user",
126
- parts=[types.Part.from_text(text=str(user_prompt))],
127
- ),
128
- ]
129
-
130
- generate_content_config = types.GenerateContentConfig(
131
- thinking_config=types.ThinkingConfig(thinking_budget=0),
132
- response_mime_type="application/json",
133
- system_instruction=[types.Part.from_text(text=system_prompt)],
134
- )
135
-
136
- response = ""
137
- for chunk in client.models.generate_content_stream(
138
- model=model,
139
- contents=contents,
140
- config=generate_content_config,
141
- ):
142
- if chunk.text:
143
- response += chunk.text
144
- return response
145
-
146
-
147
- @app.get("/")
148
- async def read_root():
149
- return {"message": "Welcome to the FastAPI Hugging Face Space API with Data Analysis!"}
150
-
151
- @app.post("/analyze_data/")
152
- async def analyze_data(file: UploadFile = File(...)):
153
- """
154
- Uploads a file, preprocesses it, and generates a summary and recommended charts.
155
- """
156
- file_path = os.path.join(TMP_DIR, file.filename)
157
- try:
158
- # Save the uploaded file to the temporary directory
159
- with open(file_path, "wb") as buffer:
160
- shutil.copyfileobj(file.file, buffer)
161
-
162
- # Load and preprocess the file
163
- df = load_file(file_path)
164
- df_clean = preprocess(df)
165
-
166
- # Generate metadata and first 5 rows
167
- meta = metadata(df_clean)
168
- fiverow = df_clean.head(5).to_dict(orient="records")
169
-
170
- # Generate summary and charts using the AI model
171
- summary_json = generate_summary(meta, fiverow)
172
-
173
- # Clean up the uploaded file after processing
174
- os.remove(file_path)
175
-
176
- return json.loads(summary_json) # Return the parsed JSON response
177
- except ValueError as ve:
178
- raise HTTPException(status_code=400, detail=str(ve))
179
- except Exception as e:
180
- raise HTTPException(status_code=500, detail=f"An error occurred during data analysis: {e}")
181
-
182
- # The following endpoints are kept for general file management but are not directly used by the new /analyze_data endpoint.
183
- # They can be removed if not needed, or modified to work with the /tmp directory.
184
- @app.post("/uploadfile/")
185
- async def create_upload_file(file: UploadFile = File(...)):
186
- """
187
- Uploads a single file to the temporary directory.
188
- """
189
- file_path = os.path.join(TMP_DIR, file.filename)
190
- try:
191
- with open(file_path, "wb") as buffer:
192
- shutil.copyfileobj(file.file, buffer)
193
- return {"filename": file.filename, "message": f"File '{file.filename}' uploaded successfully to {TMP_DIR}"}
194
- except Exception as e:
195
- raise HTTPException(status_code=500, detail=f"Could not upload file: {e}")
196
-
197
- @app.post("/uploadfiles/")
198
- async def create_upload_files(files: List[UploadFile] = File(...)):
199
- """
200
- Uploads multiple files to the temporary directory.
201
- """
202
- uploaded_files = []
203
- for file in files:
204
- file_path = os.path.join(TMP_DIR, file.filename)
205
- try:
206
- with open(file_path, "wb") as buffer:
207
- shutil.copyfileobj(file.file, buffer)
208
- uploaded_files.append({"filename": file.filename, "path": file_path})
209
- except Exception as e:
210
- raise HTTPException(status_code=500, detail=f"Could not upload file '{file.filename}': {e}")
211
- return {"message": f"Successfully uploaded {len(uploaded_files)} files to {TMP_DIR}", "files": uploaded_files}
212
-
213
- @app.get("/list_files/")
214
- async def list_uploaded_files():
215
- """
216
- Lists all files currently in the temporary directory.
217
- """
218
- if not os.path.exists(TMP_DIR):
219
- return {"message": "Temporary directory does not exist or is empty."}
220
-
221
- files = os.listdir(TMP_DIR)
222
- return {"files": files, "path": TMP_DIR}
223
-
224
- @app.get("/download_file/{filename}")
225
- async def download_file(filename: str):
226
- """
227
- Downloads a specific file from the temporary directory.
228
- """
229
- file_path = os.path.join(TMP_DIR, filename)
230
- if not os.path.exists(file_path):
231
- raise HTTPException(status_code=404, detail="File not found.")
232
-
233
- # In a real application, you would return a FileResponse here.
234
- # For this example, we'll just confirm the file exists.
235
- return {"message": f"File '{filename}' found at {file_path}. In a real app, this would be downloaded."}
236
-
237
- @app.post("/process_file/{filename}")
238
- async def process_file_data(filename: str):
239
- """
240
- Example endpoint to process data from an uploaded file.
241
- This assumes the file is already uploaded to the temporary directory.
242
- """
243
- file_path = os.path.join(TMP_DIR, filename)
244
- if not os.path.exists(file_path):
245
- raise HTTPException(status_code=404, detail="File not found. Please upload it first.")
246
-
247
- try:
248
- with open(file_path, "r") as f:
249
- content = f.readlines()[:5] # Read first 5 lines
250
- return {"filename": filename, "processed_content_sample": content, "message": "File processed successfully."}
251
- except Exception as e:
252
- raise HTTPException(status_code=500, detail=f"Error processing file: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, UploadFile, File, HTTPException
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from fastapi.responses import JSONResponse
4
+ import pandas as pd
5
+ import os
6
+ import json
7
+ import tempfile
8
+ import shutil
9
+ from typing import Optional
10
+ from pydantic import BaseModel
11
+ from google import genai
12
+ from google.genai import types
13
+ import logging
14
+
15
+ # Setup logging
16
+ logging.basicConfig(level=logging.INFO)
17
+ logger = logging.getLogger(__name__)
18
+
19
+ app = FastAPI(title="Data Analysis API", version="1.0.0")
20
+
21
+ # Add CORS middleware
22
+ app.add_middleware(
23
+ CORSMiddleware,
24
+ allow_origins=["*"], # In production, replace with your frontend domain
25
+ allow_credentials=True,
26
+ allow_methods=["*"],
27
+ allow_headers=["*"],
28
+ )
29
+
30
+ # Response models
31
+ class AnalysisResponse(BaseModel):
32
+ summary: dict
33
+ chart_data: dict
34
+ metadata: dict
35
+
36
+ class ErrorResponse(BaseModel):
37
+ error: str
38
+ details: Optional[str] = None
39
+
40
+ # Ensure tmp directory exists
41
+ os.makedirs("/tmp", exist_ok=True)
42
+
43
+ def load_file_from_upload(file_path: str, original_filename: str):
44
+ """Load file from uploaded temporary file"""
45
+ try:
46
+ ext = os.path.splitext(original_filename)[-1].lower()
47
+ if ext == ".csv":
48
+ df = pd.read_csv(file_path)
49
+ elif ext in [".xls", ".xlsx"]:
50
+ # For Excel files, we'll take the first sheet by default
51
+ # In a production app, you might want to let users choose
52
+ df = pd.read_excel(file_path, sheet_name=0)
53
+ else:
54
+ raise ValueError(f"Unsupported file type: {ext}")
55
+ return df.copy()
56
+ except Exception as e:
57
+ logger.error(f"Error loading file: {str(e)}")
58
+ raise HTTPException(status_code=400, detail=f"Error loading file: {str(e)}")
59
+
60
+ def preprocess(df, drop_thresh=0.5):
61
+ """Preprocess the dataframe"""
62
+ try:
63
+ df = df.copy()
64
+ df.columns = [str(c).strip().lower().replace(" ", "_") for c in df.columns]
65
+ df = df.loc[:, df.isnull().mean() < drop_thresh]
66
+
67
+ for col in df.columns:
68
+ if pd.api.types.is_numeric_dtype(df[col]):
69
+ df.loc[:, col] = df[col].fillna(df[col].median())
70
+ elif pd.api.types.is_datetime64_any_dtype(df[col]):
71
+ df.loc[:, col] = df[col].fillna(pd.Timestamp('1970-01-01'))
72
+ else:
73
+ df.loc[:, col] = df[col].fillna("Unknown")
74
+
75
+ for col in df.columns:
76
+ if df[col].dtype == 'object':
77
+ try:
78
+ df.loc[:, col] = pd.to_numeric(df[col])
79
+ except:
80
+ pass
81
+
82
+ df = df.drop_duplicates()
83
+ return df
84
+ except Exception as e:
85
+ logger.error(f"Error preprocessing data: {str(e)}")
86
+ raise HTTPException(status_code=500, detail=f"Error preprocessing data: {str(e)}")
87
+
88
+ def get_metadata(df):
89
+ """Get dataframe metadata"""
90
+ return {
91
+ "rows": df.shape[0],
92
+ "columns": df.shape[1],
93
+ "column_names": list(df.columns),
94
+ "column_types": df.dtypes.astype(str).to_dict(),
95
+ "unique_values": {col: df[col].nunique() for col in df.columns}
96
+ }
97
+
98
+ def generate_summary(meta, fiverow):
99
+ """Generate AI summary using Google Gemini"""
100
+ try:
101
+ # Get API key from environment variable
102
+ api_key = os.getenv("GEMINI_API_KEY")
103
+ if not api_key:
104
+ raise HTTPException(status_code=500, detail="GEMINI_API_KEY environment variable not set")
105
+
106
+ client = genai.Client(api_key=api_key)
107
+ model = "gemini-2.5-flash-lite"
108
+
109
+ system_prompt = """
110
+ You are a strict JSON generator.
111
+ Input contains:
112
+ - meta: dataframe metadata
113
+ - fiverow: first 5 records of dataframe
114
+
115
+ You must output JSON with the following structure:
116
+ {
117
+ "summary": "<short natural language overview of dataset>",
118
+ "recommended_charts": [
119
+ {
120
+ "type": "<one of: bar, pie, timeseries, histogram, scatter, multiple_columns, stacked_bar, heatmap>",
121
+ "title": "<short title for chart>",
122
+ "columns": ["<col1>", "<col2>", "..."],
123
+ "python_code": "<full runnable Python code using seaborn/matplotlib that produces the chart>"
124
+ },
125
+ ...
126
+ ]
127
+ }
128
+
129
+ Mandatory rules:
130
+ - Always produce syntactically valid JSON ONLY. No text outside the JSON object.
131
+ - Provide at least these chart types somewhere in recommended_charts: bar, pie, timeseries, histogram, scatter, multiple_columns, stacked_bar, heatmap.
132
+ - Use only column names that appear in meta['column_names'].
133
+ - The python_code string must be self-contained and runnable assuming a variable `df` exists containing the full cleaned DataFrame. Start the code with imports:
134
+ import pandas as pd
135
+ import seaborn as sns
136
+ import matplotlib.pyplot as plt
137
+ and include any necessary preprocessing steps (e.g., parsing dates).
138
+ - For timeseries charts ensure the datetime column is parsed (`pd.to_datetime`) before plotting.
139
+ - For multiple_columns provide a pairplot or facetgrid example that uses up to 4 numeric columns or sensible categorical splits.
140
+ - For stacked_bar, show aggregation code (groupby + unstack) and plotting with df.plot(kind='bar', stacked=True).
141
+ - For heatmap, compute correlation matrix and plot sns.heatmap with annotations.
142
+ - For pie charts, ensure grouping/aggregation when there are >20 unique categories (group small categories into 'Other').
143
+ - For histogram and scatter include axis labels and tight_layout; include plt.show() at the end.
144
+ - Keep code minimal but complete so a user can copy-paste and run (assume seaborn, matplotlib, pandas installed).
145
+ - For each chart add a sensible "columns" list showing which columns the code uses.
146
+ - Do not include examples using columns not present in meta.
147
+ - Do not include more than 10 recommended_charts.
148
+ - Ensure strings inside the JSON are escaped properly so the JSON parses.
149
+
150
+ Produce concise natural-language one-line summary in "summary". Ensure JSON is parseable by json.loads in Python.
151
+ """
152
+
153
+ user_prompt = {
154
+ "meta": meta,
155
+ "fiverow": fiverow
156
+ }
157
+
158
+ contents = [
159
+ types.Content(
160
+ role="user",
161
+ parts=[types.Part.from_text(text=str(user_prompt))],
162
+ ),
163
+ ]
164
+
165
+ generate_content_config = types.GenerateContentConfig(
166
+ thinking_config=types.ThinkingConfig(thinking_budget=0),
167
+ response_mime_type="application/json",
168
+ system_instruction=[types.Part.from_text(text=system_prompt)],
169
+ )
170
+
171
+ response = ""
172
+ for chunk in client.models.generate_content_stream(
173
+ model=model,
174
+ contents=contents,
175
+ config=generate_content_config,
176
+ ):
177
+ if chunk.text:
178
+ response += chunk.text
179
+
180
+ return response
181
+ except Exception as e:
182
+ logger.error(f"Error generating summary: {str(e)}")
183
+ raise HTTPException(status_code=500, detail=f"Error generating AI summary: {str(e)}")
184
+
185
+ def flatten_columns(df):
186
+ """Flatten MultiIndex columns"""
187
+ if isinstance(df.columns, pd.MultiIndex):
188
+ df.columns = ['_'.join(map(str, col)).strip() for col in df.columns.values]
189
+ return df
190
+
191
+ def extract_chart_data_json_by_type(summary_json: str, df):
192
+ """Extract chart data grouped by type"""
193
+ try:
194
+ data = json.loads(summary_json)
195
+ result = {}
196
+
197
+ for chart in data.get("recommended_charts", []):
198
+ chart_type = chart.get("type")
199
+ columns = chart.get("columns", [])
200
+ title = chart.get("title", "unnamed_chart")
201
+
202
+ if chart_type not in result:
203
+ result[chart_type] = []
204
+
205
+ try:
206
+ if chart_type == "bar":
207
+ df_agg = df[columns].groupby(columns[0]).sum(numeric_only=True).reset_index()
208
+ chart_data = df_agg.to_dict(orient="records")
209
+ elif chart_type == "stacked_bar":
210
+ df_agg = df.groupby(columns).sum(numeric_only=True).unstack()
211
+ df_agg = flatten_columns(df_agg)
212
+ chart_data = df_agg.fillna(0).to_dict(orient="records")
213
+ elif chart_type == "pie":
214
+ col = columns[0]
215
+ counts = df[col].value_counts()
216
+ if len(counts) > 20:
217
+ top = counts.nlargest(19)
218
+ others = counts.iloc[19:].sum()
219
+ counts = pd.concat([top, pd.Series({'Other': others})])
220
+ chart_data = counts.reset_index().rename(columns={'index': col, col: 'value'}).to_dict(orient="records")
221
+ elif chart_type == "histogram":
222
+ chart_data = df[columns[0]].dropna().tolist()
223
+ elif chart_type == "scatter":
224
+ chart_data = df[columns].to_dict(orient="records")
225
+ elif chart_type == "timeseries":
226
+ df_copy = df[columns].copy()
227
+ for c in columns:
228
+ df_copy[c] = pd.to_datetime(df_copy[c], errors='coerce')
229
+ chart_data = df_copy.astype(str).to_dict(orient="records")
230
+ elif chart_type == "multiple_columns":
231
+ chart_data = df[columns].to_dict(orient="records")
232
+ elif chart_type == "heatmap":
233
+ corr_df = df[columns].corr().fillna(0)
234
+ chart_data = flatten_columns(corr_df).to_dict()
235
+ else:
236
+ chart_data = []
237
+
238
+ except Exception as e:
239
+ chart_data = {"error": str(e)}
240
+
241
+ result[chart_type].append({"title": title, "data": chart_data})
242
+
243
+ return result
244
+ except Exception as e:
245
+ logger.error(f"Error extracting chart data: {str(e)}")
246
+ raise HTTPException(status_code=500, detail=f"Error extracting chart data: {str(e)}")
247
+
248
+ @app.get("/")
249
+ async def root():
250
+ return {"message": "Data Analysis API is running"}
251
+
252
+ @app.get("/health")
253
+ async def health_check():
254
+ return {"status": "healthy"}
255
+
256
+ @app.post("/analyze", response_model=AnalysisResponse)
257
+ async def analyze_data(file: UploadFile = File(...)):
258
+ """
259
+ Analyze uploaded CSV/Excel file and return AI-generated summary with chart recommendations
260
+ """
261
+ if not file.filename:
262
+ raise HTTPException(status_code=400, detail="No file provided")
263
+
264
+ # Check file type
265
+ allowed_extensions = ['.csv', '.xls', '.xlsx']
266
+ file_ext = os.path.splitext(file.filename)[-1].lower()
267
+ if file_ext not in allowed_extensions:
268
+ raise HTTPException(
269
+ status_code=400,
270
+ detail=f"Unsupported file type. Allowed: {', '.join(allowed_extensions)}"
271
+ )
272
+
273
+ # Create temporary file
274
+ with tempfile.NamedTemporaryFile(delete=False, suffix=file_ext) as tmp_file:
275
+ try:
276
+ # Save uploaded file to temporary location
277
+ shutil.copyfileobj(file.file, tmp_file)
278
+ tmp_file_path = tmp_file.name
279
+
280
+ # Process the file
281
+ df = load_file_from_upload(tmp_file_path, file.filename)
282
+ df_clean = preprocess(df)
283
+
284
+ # Generate metadata
285
+ meta = get_metadata(df_clean)
286
+ fiverow = df_clean.head(5).to_dict(orient="records")
287
+
288
+ # Generate AI summary
289
+ summary_json = generate_summary(meta, fiverow)
290
+ summary_data = json.loads(summary_json)
291
+
292
+ # Extract chart data by type
293
+ chart_data = extract_chart_data_json_by_type(summary_json, df_clean)
294
+
295
+ return AnalysisResponse(
296
+ summary=summary_data,
297
+ chart_data=chart_data,
298
+ metadata=meta
299
+ )
300
+
301
+ except Exception as e:
302
+ logger.error(f"Error processing file: {str(e)}")
303
+ raise HTTPException(status_code=500, detail=str(e))
304
+ finally:
305
+ # Clean up temporary file
306
+ try:
307
+ os.unlink(tmp_file_path)
308
+ except:
309
+ pass
310
+
311
+ @app.exception_handler(HTTPException)
312
+ async def http_exception_handler(request, exc):
313
+ return JSONResponse(
314
+ status_code=exc.status_code,
315
+ content={"error": exc.detail}
316
+ )
317
+
318
+ @app.exception_handler(Exception)
319
+ async def general_exception_handler(request, exc):
320
+ logger.error(f"Unhandled exception: {str(exc)}")
321
+ return JSONResponse(
322
+ status_code=500,
323
+ content={"error": "Internal server error", "details": str(exc)}
324
+ )
325
+
326
+ if __name__ == "__main__":
327
+ import uvicorn
328
+ uvicorn.run(app, host="0.0.0.0", port=7860)