diff --git a/README.md b/README.md index 47689661..3add7f39 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Work in Progress +## Tulane Dev Integration Branch + Sawt is a tool designed to bridge the communication gap between New Orleanians and their city council representatives. ## Prerequisites diff --git a/packages/googlecloud/functions/getanswer/inquirer.py b/packages/googlecloud/functions/getanswer/inquirer.py index 9bb1b3c2..ca005b98 100644 --- a/packages/googlecloud/functions/getanswer/inquirer.py +++ b/packages/googlecloud/functions/getanswer/inquirer.py @@ -171,7 +171,9 @@ def get_indepth_response_from_query(df, db, query, k): query = transform_query_for_date(query) doc_list = db.similarity_search_with_score(query, k=k) + docs = sort_retrived_documents(doc_list) + docs_page_content = append_metadata_to_content(doc_list) template = """ @@ -245,3 +247,4 @@ def answer_query( final_response = route_question(df, db_general, db_in_depth, query, response_type) return final_response + diff --git a/packages/googlecloud/functions/getanswer/main.py b/packages/googlecloud/functions/getanswer/main.py index 07717e30..d841ce2a 100644 --- a/packages/googlecloud/functions/getanswer/main.py +++ b/packages/googlecloud/functions/getanswer/main.py @@ -2,23 +2,28 @@ import time import math -import google.cloud.logging +# blocking google cloud for local deploy +#import google.cloud.logging import functions_framework from supabase import create_client - +from dotenv import find_dotenv, load_dotenv from helper import parse_field, get_dbs from inquirer import answer_query import os import json -logging_client = google.cloud.logging.Client() -logging_client.setup_logging() +#logging_client = google.cloud.logging.Client() +#logging_client.setup_logging() API_VERSION = "0.0.1" db_general, db_in_depth, voting_roll_df = get_dbs() # Setup Supabase client +load_dotenv(find_dotenv()) + +# disabled for local deploy, reenable for web +""" try: supabase_url = os.environ["SUPABASE_URL_PRODUCTION"] supabase_key = os.environ["SUPABASE_SERVICE_KEY_PRODUCTION"] @@ -30,6 +35,7 @@ raise ValueError("Supabase URL and key must be set in environment variables") supabase = create_client(supabase_url, supabase_key) +""" def update_supabase(responses, citations, card_id, processing_time_ms): transformed_citations = [] @@ -115,8 +121,15 @@ def getanswer(request): end = time.time() elapsed = int((end - start) * 1000) + + # + return(answer) + + # disabled for local deployment, reenable for web + """ update_supabase(responses_data, citations_data, card_id, elapsed) logging.info(f"Completed getanswer in {elapsed} seconds") print(f"\n\t--------- Completed getanswer in {elapsed} seconds --------\n") return ("Answer successfully submitted to Supabase", 200, headers) + """ diff --git a/packages/googlecloud/functions/getanswer/process_public_queries.py b/packages/googlecloud/functions/getanswer/process_public_queries.py new file mode 100644 index 00000000..32f080fc --- /dev/null +++ b/packages/googlecloud/functions/getanswer/process_public_queries.py @@ -0,0 +1,50 @@ +import pandas as pd +import numpy as np +import requests +import csv +import json +from tqdm import tqdm + +# Input CSV file with 'title' column +input_csv = "/Users/haydenoutlaw/Desktop/card_rows_export_2023-11-29.csv" +output_csv = "/Users/haydenoutlaw/Desktop/gpt4-varied-11-29.csv" + +# point to getanswer server +api_endpoint = "http://localhost:8080" + +# list of k values +k_list = [5, 10, 15] + +# get response from local getanswer server, store answers +def make_api_call(title, k_inp): + payload = {"query": title, "response_type": "in_depth", "card_id": 1, "k": k_inp} + response = requests.post(f"{api_endpoint}", json=payload) + rdict = json.loads(response.text) + card_type_out = rdict["card_type"] + citations_out = rdict["citations"] + responses_out = rdict["responses"] + return card_type_out, citations_out, responses_out, k_inp + +# Open CSV file in append mode +with open(output_csv, 'a', newline='', encoding='utf-8') as csv_file: + # define csv out file + csv_writer = csv.writer(csv_file) + csv_writer.writerow(["query", "response_id", "card_type", "citations", "responses", "k"]) + + # read inputs + df = pd.read_csv(input_csv) + + + print("Connected to getanswer at", api_endpoint) + print("K Values", k_list) + print("Generating Responses....") + + + # for all queries, get answers and write out one at a time + tqiter = enumerate(tqdm(df["title"])) + for i, query in tqiter: + for k_val in k_list: + card_type, citations, responses, k = make_api_call(query, k_val) + csv_writer.writerow([query, i, card_type, citations, responses, k]) + +print(f"Results saved to '{output_csv}'.") \ No newline at end of file diff --git a/packages/transcription/.gitignore b/packages/transcription/.gitignore new file mode 100644 index 00000000..333e1469 --- /dev/null +++ b/packages/transcription/.gitignore @@ -0,0 +1,8 @@ +.env +.log +__pycache__/ +transcripts-data/ +audio/ +cred/ +.vscode/ + diff --git a/packages/transcription/transcribe/README.md b/packages/transcription/transcribe/README.md new file mode 100644 index 00000000..b91b8e41 --- /dev/null +++ b/packages/transcription/transcribe/README.md @@ -0,0 +1,19 @@ +## TU Capstone- Transcription + +A generic API for fetching YouTube Audio and Transcripts. + +#### Required Credentials + - YOUTUBE_API_KEY + - GOOGLE_APPLICATION_CREDENTIALS +Create a cred folder containing cred.env variables according to dotenv configuration. + +### transcripts.py +Retrieves & downloads the x-most recent video transcripts from a YouTube Channel. + +### monitor.py +Retrieves & downloads the x-most recent video audio mp4s from a YouTube Channel. Future implemention should consider using Windows Task Scheduler to periodically monitor channel for new videos. + +#### Oauth.py +Helper authentication function. + + diff --git a/packages/transcription/transcribe/monitor.py b/packages/transcription/transcribe/monitor.py new file mode 100644 index 00000000..f9863326 --- /dev/null +++ b/packages/transcription/transcribe/monitor.py @@ -0,0 +1,71 @@ +from googleapiclient.discovery import build +#import youtube_dl Has BEEN DEPRECATED BY GERMAN GOVERNMENT +import os +from dotenv import load_dotenv +from pytube import YouTube +import oauth +# Initialize the YouTube Data API client + +env_vars = oauth.import_env_vars() +YOUTUBE_API_KEY = env_vars.get('YOUTUBE_API_KEY') +youtube = build('youtube', 'v3', developerKey=YOUTUBE_API_KEY) + +# Specify the YouTube channel ID +channel_id = 'UC8oPEsQe9a0v6TdJ4K_QXoA' # New Orleans City Council + +def get_latest_videos(channel_id, max_results=5): + """ + Fetches the latest x-number of videos from a YouTube channel. + + Args: + channel_id (str): The ID of the YouTube channel to monitor. + max_results (int): The maximum number of latest videos to fetch. Default is 5. + + Returns: + list: A list of video IDs for the latest videos. + """ + # Fetch channel details to get the ID of the uploads playlist + request = youtube.channels().list( + part='contentDetails', + id=channel_id + ) + response = request.execute() + + if not response.get('items'): + raise ValueError(f"No channel found with ID {channel_id}") + + playlist_id = response['items'][0]['contentDetails']['relatedPlaylists']['uploads'] + + request = youtube.playlistItems().list( + part='snippet', + playlistId=playlist_id, + maxResults=max_results + ) + response = request.execute() + + video_ids = [item['snippet']['resourceId']['videoId'] for item in response['items']] + + return video_ids + +def download_audio(video_ids): + """ + Downloads the audio of a list of YouTube videos using pytube. + + Args: + video_ids (list): A list of YouTube video IDs to download the audio for. + + Downloads: mp4 audio files of the desired Youtube videos. + """ + for video_id in video_ids: + yt = YouTube(f'https://www.youtube.com/watch?v={video_id}') + ys = yt.streams.filter(only_audio=True).first() + + # Download the audio stream to the specified output path + print(f'Downloading audio for {video_id}...') + ys.download(output_path=r'transcripts-data\audio', filename=video_id+".mp4") + +# Get the latest videos +video_ids = get_latest_videos(channel_id, 10) + +# Download the audio of the new videos +download_audio(video_ids) \ No newline at end of file diff --git a/packages/transcription/transcribe/oauth.py b/packages/transcription/transcribe/oauth.py new file mode 100644 index 00000000..07082eac --- /dev/null +++ b/packages/transcription/transcribe/oauth.py @@ -0,0 +1,21 @@ + +import os +from dotenv import load_dotenv + +def import_env_vars(): + os.chdir(r"packages\transcription") + load_dotenv(r"cred\cred.env") + + # Get credentials from environment variables + YOUTUBE_API_KEY = os.getenv("YOUTUBE_API_KEY") + CLIENT_ID = os.getenv("CLIENT_ID") + CLIENT_SECRET = os.getenv("CLIENT_SECRET") + GOOGLE_APPLICATION_CREDENTIALS= os.getenv("GOOGLE_APPLICATION_CREDENTIALS") + + os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = GOOGLE_APPLICATION_CREDENTIALS + + return { "YOUTUBE_API_KEY": YOUTUBE_API_KEY, + "CLIENT_ID": CLIENT_ID, + "CLIENT_SECRET": CLIENT_SECRET, + "GOOGLE_APPLICATION_CREDENTIALS": GOOGLE_APPLICATION_CREDENTIALS + } \ No newline at end of file diff --git a/packages/transcription/transcribe/transcripts.py b/packages/transcription/transcribe/transcripts.py new file mode 100644 index 00000000..4c256121 --- /dev/null +++ b/packages/transcription/transcribe/transcripts.py @@ -0,0 +1,67 @@ +from youtube_transcript_api import YouTubeTranscriptApi +from googleapiclient.discovery import build +import oauth +import json +import os + +# Get credentials from environment variables +env_vars = oauth.import_env_vars() +YOUTUBE_API_KEY = env_vars.get("YOUTUBE_API_KEY") +CLIENT_ID = env_vars.get("CLIENT_ID") +CLIENT_SECRET = env_vars.get("CLIENT_SECRET") +GOOGLE_APPLICATION_CREDENTIALS= env_vars.get("GOOGLE_APPLICATION_CREDENTIALS") + +def get_latest_videos(channel_id, max_results=5): + + """ + Fetches the latest x-number of videos from a YouTube channel. + + Args: + channel_id (str): The ID of the YouTube channel to monitor. + max_results (int): The maximum number of latest videos to fetch. Default is 5. + + Returns: + list: A list of video IDs for the latest videos. + """ + youtube = build('youtube', 'v3', developerKey=YOUTUBE_API_KEY) + + # Fetch channel details to get the ID of the uploads playlist + request = youtube.channels().list( + part='contentDetails', + id=channel_id + ) + response = request.execute() + + if not response.get('items'): + raise ValueError(f"No channel found with ID {channel_id}") + + playlist_id = response['items'][0]['contentDetails']['relatedPlaylists']['uploads'] + + request = youtube.playlistItems().list( + part='snippet', + playlistId=playlist_id, + maxResults=max_results + ) + response = request.execute() + + video_ids = [item['snippet']['resourceId']['videoId'] for item in response['items']] + + return video_ids + +def download_transcripts(video_ids): + for video_id in video_ids: + try: + # Grabs transcript for the video + transcript = YouTubeTranscriptApi.get_transcript(video_id) + print(transcript) + with open(f'transcripts-data\\YT_transcripts\\{video_id}_transcript.json', 'w+', encoding='utf-8') as file: + json.dump(transcript, file) + + print(f'Transcript for {video_id} saved successfully.') + + except Exception as e: + print(f'An error occurred while fetching the transcript for {video_id}: {e}') + +channel_id = "UC8oPEsQe9a0v6TdJ4K_QXoA" +video_ids = get_latest_videos(channel_id, 10) +download_transcripts(video_ids) \ No newline at end of file diff --git a/packages/transcription/whisper-model/README.md b/packages/transcription/whisper-model/README.md new file mode 100644 index 00000000..92dcc82e --- /dev/null +++ b/packages/transcription/whisper-model/README.md @@ -0,0 +1,28 @@ +# HF Whisper Transcript App +Application of [OpenAI Whisper-V2](https://huggingface.co/openai/whisper-large-v2) for audio file transcription. + + +## To Run +Configure [README.md]('README.md') +```yml +model: + #model size + #tiny, base, small, medium, large, large_v2 + size: "tiny" + # device for pytorch processing + device: "cpu" + # chunk length for audio processing + chunk_length: "10" + # batch size + batch_size: 1 +audio: + # path to audio file to process + path: "audio/trial_meeting.mp3" +transcript: + # location to save transcript + save_loc: "transcripts/trial_meeting_transcript.txt" +``` +Execute from CL: +```bash +python transcribe.py transcribe_config.yml +``` \ No newline at end of file diff --git a/packages/transcription/whisper-model/long_transcription.ipynb b/packages/transcription/whisper-model/long_transcription.ipynb new file mode 100644 index 00000000..65595181 --- /dev/null +++ b/packages/transcription/whisper-model/long_transcription.ipynb @@ -0,0 +1,247 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "582718ec5efa4efa9c8e3e2f6cbc1dfc", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "Downloading (…)lve/main/config.json: 0%| | 0.00/1.94k [00:00(randint(0,177)); + const [userName, setUserName] = useState(""); + const [currentIndex, setCurrentIndex] = useState(0); + const [fullData, setFullData] = useState> | null>(null); + + const [cardArray, setCardArray] = useState> | null>(null); + + + // Not the best way to do it-- we really should make each of these a new page and the next/prev buttons + // should be linked to the next/prev page. But this is a quick fix for now. + const question_idArray = Array.from({ length: 98 }, (_, index) => index); + + + const handlePrevClick = () => { + if (fullData) { + setCardArray(fullData); + //wraps around + setCurrentIndex((currentIndex - 1 + question_idArray.length) % question_idArray.length); + } else { + alert("Please wait for the rest of the cards to finish loading..."); + } + }; + + const handleNextClick = () => { + if (fullData) { + setCardArray(fullData); + //wraps around + setCurrentIndex((currentIndex + 1) % question_idArray.length); + } else { + alert("Please wait for the rest of the cards to finish loading..."); + } + + }; + + //const handleNameChange = (e) => { + const handleNameChange = (e: React.ChangeEvent) => { + setUserName(e.target.value); + } + + useEffect(() => { + const getCard = async () => { + try { + const cardsArray: Array> = []; + const { data: cards, error } = await supabase + .from('sawt_cards') + .select('*') + .eq("question_id", 0); + if (cards) { + cardsArray.push(cards); + } + setCardArray(cardsArray); + console.log(cards); + }catch (error) { + console.error("Error fetching cards: ", error); + // Handle the error appropriately in your UI + } + getCards(); + } + getCard(); + }, []); // Run this effect only once when the component mounts + + + const getCards = async () => { + const cardsArray: Array> = []; + try { + for (let i = 1; i <= question_idArray.length; i++) { + const { data: cards, error } = await supabase + .from('sawt_cards') + .select('*') + .eq("question_id", i); + + if (error) { + console.error("Error fetching cards: ", error); + // Handle the error appropriately in your UI + } + console.log(cards); + + if (cards) { + cardsArray.push(cards); + } + } + setFullData(cardsArray); + console.log(fullData); + //setCurrentIndex(Math.floor(Math.random() * cardsArray.length)); + + + } catch (error) { + console.error("Error fetching cards: ", error); + // Handle the error appropriately in your UI + } + }; + + + if (!cardArray) { + return
Loading...
; + } + + return ( + <> +
+ +
+ +
+
+ +
+
+ +
+ +
+ +
+
+ + ); +} diff --git a/packages/web/components/BetaCard.tsx b/packages/web/components/BetaCard.tsx index 881e91ff..e4257c98 100644 --- a/packages/web/components/BetaCard.tsx +++ b/packages/web/components/BetaCard.tsx @@ -77,6 +77,41 @@ const BetaCard = ({ card }: { card: ICard }) => { }; }, [card.id]); + const handleCommentSubmit = async () => { + const newComment = { + card_id: card.id, + content: commentContent, + display_name: displayName, + created_at: new Date(), + }; + + + setComments((prevComments) => + prevComments + ? prevComments.filter((comment) => comment !== newComment) + : null + ); + + setDisplayName(""); // Resetting display name + setCommentContent(""); // Resetting comment content + + try { + const { data, error } = await supabase + .from("comments") + .insert([newComment]); + if (error) throw error; + setDisplayName(""); // Resetting display name after successful post + setCommentContent(""); // Resetting comment content after successful post + } catch (error) { + // If there's an error, revert the change to the comments + setComments((prevComments) => + prevComments + ? prevComments.filter((comment) => comment !== newComment) + : null + ); + } + }; + return (
{/* Card Header */} diff --git a/packages/web/components/CommentBoxes.tsx b/packages/web/components/CommentBoxes.tsx new file mode 100644 index 00000000..0b7cd545 --- /dev/null +++ b/packages/web/components/CommentBoxes.tsx @@ -0,0 +1,54 @@ + +import { ICard } from "@/lib/api"; +import { faComment } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useState } from "react"; + +interface CommentBoxProps { + scores: Record + card: ICard; + onSubmit: (data: { comment: string, card: ICard, scores: Record }) => void; + onReset: () => void; +} + +export default function CommentBox({ onSubmit, card, scores, onReset }: CommentBoxProps) { + const [comment, setComment] = useState(""); + // const [scores, setRubricScores] = useState>({}); + + const handleSubmit = () => { + onSubmit({ comment, card, scores}); + setComment(""); + onReset(); // Reset the scores after submission + }; + + + return ( + +
+
+ + +
+
+ +
+
+ ); +} \ No newline at end of file diff --git a/packages/web/components/Navbar.tsx b/packages/web/components/Navbar.tsx index 2ebaf410..3ae1e3a2 100644 --- a/packages/web/components/Navbar.tsx +++ b/packages/web/components/Navbar.tsx @@ -15,6 +15,10 @@ export const navLinks = [ id: "tips", title: "How to use", }, + { + id: "userFeedback", + title: "User Feedback", + }, ]; const Navbar = () => { diff --git a/packages/web/components/Rubric.tsx b/packages/web/components/Rubric.tsx new file mode 100644 index 00000000..3e6dfadf --- /dev/null +++ b/packages/web/components/Rubric.tsx @@ -0,0 +1,73 @@ + + +interface RubricProps { + criteria: Array<{ + id: string; + description: string; + }>; + scores: Record; + onScoreChange: (criterionId: string, score: number) => void; +} + + +const Rubric: React.FC = ({ criteria, scores, onScoreChange }) => { // This state will hold the scores for each criterion + + + + const circleButtonStyle = (score: number, criterionId: string) => ({ + width: '40px', // Circle diameter + height: '40px', // Circle diameter + borderRadius: '50%', // Make it round + margin: '5px', + fontWeight: scores[criterionId] === score ? 'bold' : 'normal', + outline: 'none', + border: scores[criterionId] === score ? '2px solid blue' : '1px solid grey' + }); + const submitButtonStyle = { + padding: '10px 20px', + fontSize: '16px', + color: 'white', + backgroundColor: '#007bff', + border: 'none', + borderRadius: '5px', + cursor: 'pointer', + outline: 'none', + marginTop: '20px', + }; + + const containerStyle: React.CSSProperties = { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + flexDirection: 'column', + padding: '20px', + }; + + return ( +
+
+ {criteria.map(criterion => ( +
+ +
+ {[1, 2, 3, 4, 5].map(score => ( + + ))} +
+
+ ))} + {/* */} + {/* */} + +
+
+ ); + }; + + export default Rubric; \ No newline at end of file diff --git a/packages/web/components/ThreeCardLayout.tsx b/packages/web/components/ThreeCardLayout.tsx new file mode 100644 index 00000000..d4b07a00 --- /dev/null +++ b/packages/web/components/ThreeCardLayout.tsx @@ -0,0 +1,143 @@ + +"use client"; + +import { ICard } from "@/lib/api"; +// import { CARD_SHOW_PATH, getPageURL } from "@/lib/paths"; +import { supabase } from "@/lib/supabase/supabaseClient"; +// import Link from "next/link"; +import { useState } from "react"; +import CommentBox from "./CommentBoxes"; +import Rubric from '@/components/Rubric'; + + +const criteria = [ + { id: 'Accuracy', description: 'Accuracy' }, + { id: 'Helpfulness', description: 'Helpfulness' }, + { id: 'Balance', description: 'Balance' } + // Add more criteria as needed +]; + +export default function ThreeCardLayout({ cards, userName }: { cards: Array, userName: string }) { + + const [scores, setScores] = useState>({}); + + // Function to update scores + const handleScoreChange = (criterionId: string, score: number) => { + setScores(prevScores => ({ ...prevScores, [criterionId]: score })); + }; + + // Function to reset scores + const resetScores = () => { + // Reset logic - assuming each score should reset to 1 + // const resettedScores = Object.keys(scores).reduce((acc, criterionId) => { + // acc[criterionId] = 1; + // return acc; + // }, {}); + + const resetScores = { + "Accuracy": 1, + "Helpfulness": 1, + "Balance": 1 + }; + + setScores(resetScores); + }; + + //Function that sends comments to supabase under respective card.comment + const submitCommentFeedback = async ({ + scores, + comment, + card + }: { + scores: Record; + comment: string; + card: ICard; + }) => { + try { + const { data: existingCard, error: fetchError } = await supabase + .from("sawt_cards") + .select("question_id, id") + .eq("id", card.id) + .single(); + + + // order by random + // select all ids load them to an array + // then independent supabase fetch the card with that random id + // --on button click + + + + if (fetchError) { + throw fetchError; + } + const user_id = `${userName}_${Date.now()}`; + + + + const { data, error } = await supabase + .from("UserFeedback") + .insert([ + { question_id: existingCard.question_id, response_id: existingCard.id, user_id: user_id, comment: comment, accuracy: scores["Accuracy"], helpfulness: scores["Helpfulness"], balance: scores["Balance"] }, + ]) + .select() + + if (error) { + throw error; + } + } catch (error) {} + }; + + return ( +
+ {cards && cards.map((card) => ( +
+ +
+

{card.title}

+ + {/* */} + {/* LINK DOES the modal-- Need to change card.id to questionID to refer back to original card ID */} +
+
+ + {card.is_mine ? "You | " : null} + +
+ +
+ {card.responses && card.responses.map((element, index) => ( + +

+ {element.response} + +

+ ))} +
+
+
+ {/* */} + + + + {/* */} + + + + +
+
+ )) + } + +
+ + ); +} \ No newline at end of file diff --git a/packages/whisper/README.md b/packages/whisper/README.md new file mode 100644 index 00000000..92dcc82e --- /dev/null +++ b/packages/whisper/README.md @@ -0,0 +1,28 @@ +# HF Whisper Transcript App +Application of [OpenAI Whisper-V2](https://huggingface.co/openai/whisper-large-v2) for audio file transcription. + + +## To Run +Configure [README.md]('README.md') +```yml +model: + #model size + #tiny, base, small, medium, large, large_v2 + size: "tiny" + # device for pytorch processing + device: "cpu" + # chunk length for audio processing + chunk_length: "10" + # batch size + batch_size: 1 +audio: + # path to audio file to process + path: "audio/trial_meeting.mp3" +transcript: + # location to save transcript + save_loc: "transcripts/trial_meeting_transcript.txt" +``` +Execute from CL: +```bash +python transcribe.py transcribe_config.yml +``` \ No newline at end of file diff --git a/packages/whisper/long_transcription.ipynb b/packages/whisper/long_transcription.ipynb new file mode 100644 index 00000000..65595181 --- /dev/null +++ b/packages/whisper/long_transcription.ipynb @@ -0,0 +1,247 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "582718ec5efa4efa9c8e3e2f6cbc1dfc", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "Downloading (…)lve/main/config.json: 0%| | 0.00/1.94k [00:00